From 6babaa726ea6425fb6b34d98143f3aa88bf5b2d4 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Fri, 26 Dec 2025 19:48:47 +0000 Subject: [PATCH 1/2] feat(gateway): add JWT authentication with RBAC Implement comprehensive authentication and authorization system for the ros2_medkit gateway using JSON Web Tokens (JWT) with Role-Based Access Control (RBAC). Features: - OAuth2 client_credentials flow for authentication - JWT tokens with HS256/RS256 signing algorithms - Refresh token support with revocation capability - Four-tier RBAC: viewer, operator, configurator, admin - Configurable auth requirements (none/write/all) - Pre-routing middleware for endpoint protection New endpoints: - POST /api/v1/auth/authorize - Client authentication - POST /api/v1/auth/token - Token refresh - POST /api/v1/auth/revoke - Token revocation Dependencies: - jwt-cpp v0.7.0 (fetched via CMake FetchContent) - OpenSSL (system package) Auth is disabled by default for backward compatibility. Enable via `auth.enabled: true` in gateway_params.yaml. --- src/ros2_medkit_gateway/CMakeLists.txt | 30 + src/ros2_medkit_gateway/README.md | 168 +++++ .../config/gateway_params.yaml | 54 ++ .../ros2_medkit_gateway/auth_config.hpp | 136 ++++ .../ros2_medkit_gateway/auth_manager.hpp | 192 ++++++ .../ros2_medkit_gateway/auth_models.hpp | 226 +++++++ .../ros2_medkit_gateway/gateway_node.hpp | 2 + .../ros2_medkit_gateway/rest_server.hpp | 31 +- src/ros2_medkit_gateway/package.xml | 2 + src/ros2_medkit_gateway/src/auth_config.cpp | 274 ++++++++ src/ros2_medkit_gateway/src/auth_manager.cpp | 543 ++++++++++++++++ src/ros2_medkit_gateway/src/auth_models.cpp | 71 +++ src/ros2_medkit_gateway/src/gateway_node.cpp | 70 ++- src/ros2_medkit_gateway/src/rest_server.cpp | 401 ++++++++++-- .../test/test_auth.test.py | 447 +++++++++++++ .../test/test_auth_manager.cpp | 591 ++++++++++++++++++ 16 files changed, 3200 insertions(+), 38 deletions(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_config.hpp create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_manager.hpp create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_models.hpp create mode 100644 src/ros2_medkit_gateway/src/auth_config.cpp create mode 100644 src/ros2_medkit_gateway/src/auth_manager.cpp create mode 100644 src/ros2_medkit_gateway/src/auth_models.cpp create mode 100644 src/ros2_medkit_gateway/test/test_auth.test.py create mode 100644 src/ros2_medkit_gateway/test/test_auth_manager.cpp diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index beccf6e..fb867bc 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -37,6 +37,18 @@ find_package(ros2_medkit_msgs REQUIRED) find_package(PkgConfig REQUIRED) pkg_check_modules(cpp_httplib REQUIRED IMPORTED_TARGET cpp-httplib) +# Find OpenSSL (required by jwt-cpp for RS256) +find_package(OpenSSL REQUIRED) + +# Fetch jwt-cpp header-only library +include(FetchContent) +FetchContent_Declare( + jwt-cpp + GIT_REPOSITORY https://github.com/Thalhammer/jwt-cpp.git + GIT_TAG v0.7.0 +) +FetchContent_MakeAvailable(jwt-cpp) + # Include directories include_directories(include) @@ -54,6 +66,9 @@ add_library(gateway_lib STATIC src/operation_manager.cpp src/configuration_manager.cpp src/fault_manager.cpp + src/auth_config.cpp + src/auth_models.cpp + src/auth_manager.cpp ) ament_target_dependencies(gateway_lib @@ -70,6 +85,9 @@ target_link_libraries(gateway_lib PkgConfig::cpp_httplib nlohmann_json::nlohmann_json yaml-cpp::yaml-cpp + OpenSSL::SSL + OpenSSL::Crypto + jwt-cpp::jwt-cpp ) # Apply coverage flags to library @@ -121,6 +139,9 @@ if(BUILD_TESTING) ament_add_gtest(test_gateway_node test/test_gateway_node.cpp) target_link_libraries(test_gateway_node gateway_lib) + ament_add_gtest(test_auth_manager test/test_auth_manager.cpp) + target_link_libraries(test_auth_manager gateway_lib) + ament_add_gtest(test_operation_manager test/test_operation_manager.cpp) target_link_libraries(test_operation_manager gateway_lib) @@ -131,6 +152,8 @@ if(BUILD_TESTING) if(ENABLE_COVERAGE) target_compile_options(test_gateway_node PRIVATE --coverage -O0 -g) target_link_options(test_gateway_node PRIVATE --coverage) + target_compile_options(test_auth_manager PRIVATE --coverage -O0 -g) + target_link_options(test_auth_manager PRIVATE --coverage) target_compile_options(test_operation_manager PRIVATE --coverage -O0 -g) target_link_options(test_operation_manager PRIVATE --coverage) target_compile_options(test_configuration_manager PRIVATE --coverage -O0 -g) @@ -160,6 +183,13 @@ if(BUILD_TESTING) TIMEOUT 60 ) + # Add authentication integration tests + add_launch_test( + test/test_auth.test.py + TARGET test_auth + TIMEOUT 120 + ) + # Demo automotive nodes add_executable(demo_engine_temp_sensor test/demo_nodes/engine_temp_sensor.cpp diff --git a/src/ros2_medkit_gateway/README.md b/src/ros2_medkit_gateway/README.md index 2cc9052..b422214 100644 --- a/src/ros2_medkit_gateway/README.md +++ b/src/ros2_medkit_gateway/README.md @@ -421,6 +421,92 @@ curl -X DELETE "http://localhost:8080/api/v1/components/long_calibration/operati } ``` +### Authentication Endpoints + +#### POST /api/v1/auth/authorize + +Authenticate using OAuth2 client credentials flow. Returns access and refresh tokens. + +**Example (JSON):** +```bash +curl -X POST http://localhost:8080/api/v1/auth/authorize \ + -H "Content-Type: application/json" \ + -d '{ + "grant_type": "client_credentials", + "client_id": "my_client", + "client_secret": "my_secret" + }' +``` + +**Example (Form URL-encoded):** +```bash +curl -X POST http://localhost:8080/api/v1/auth/authorize \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d 'grant_type=client_credentials&client_id=my_client&client_secret=my_secret' +``` + +**Response (200 OK):** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...", + "scope": "admin" +} +``` + +**Response (401 Unauthorized - Invalid Credentials):** +```json +{ + "error": "invalid_client", + "error_description": "Invalid client credentials" +} +``` + +#### POST /api/v1/auth/token + +Refresh an access token using a refresh token. + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/auth/token \ + -H "Content-Type: application/json" \ + -d '{ + "grant_type": "refresh_token", + "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4..." + }' +``` + +**Response (200 OK):** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "bmV3IHJlZnJlc2ggdG9rZW4...", + "scope": "admin" +} +``` + +#### POST /api/v1/auth/revoke + +Revoke a refresh token to prevent further use. + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/auth/revoke \ + -H "Content-Type: application/json" \ + -d '{"token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4..."}' +``` + +**Response (200 OK):** +```json +{ + "status": "revoked" +} +``` + ### Configurations Endpoints #### GET /api/v1/components/{component_id}/configurations @@ -616,6 +702,30 @@ Cross-Origin Resource Sharing (CORS) settings for browser-based clients. CORS is | `cors.allow_credentials` | bool | `false` | Allow credentials (cookies, auth headers). Cannot be `true` with wildcard origin. | | `cors.max_age_seconds` | int | `86400` | How long browsers cache preflight response (24 hours default) | +#### Authentication Configuration + +JWT-based authentication with Role-Based Access Control (RBAC). Authentication is **disabled by default** for backward compatibility. + +| Parameter | Type | Default | Description | +| ----------------------------------- | -------- | --------------------- | --------------------------------------------------------------------------- | +| `auth.enabled` | bool | `false` | Enable/disable authentication. Set to `true` to require auth. | +| `auth.jwt_secret` | string | (required if enabled) | Secret key for HS256 signing. Must be at least 32 characters. | +| `auth.jwt_algorithm` | string | `HS256` | JWT signing algorithm: `HS256` (symmetric) or `RS256` (asymmetric). | +| `auth.token_expiry_seconds` | int | `3600` | Access token lifetime in seconds (range: 60-86400). | +| `auth.refresh_token_expiry_seconds` | int | `86400` | Refresh token lifetime in seconds (range: 300-604800). | +| `auth.require_auth_for` | string | `write` | Auth requirement: `none`, `write` (POST/PUT/DELETE only), or `all`. | +| `auth.issuer` | string | `ros2_medkit_gateway` | JWT issuer claim for token validation. | +| `auth.clients` | string[] | `[]` | Client credentials in format `client_id:client_secret:role`. | + +**Roles and Permissions:** + +| Role | Read (GET) | Operations (POST) | Configurations (PUT/DELETE) | Faults (DELETE) | +| ------------ | ---------- | ----------------- | --------------------------- | --------------- | +| `viewer` | ✅ | ❌ | ❌ | ❌ | +| `operator` | ✅ | ✅ | ❌ | ❌ | +| `configurator` | ✅ | ✅ | ✅ | ❌ | +| `admin` | ✅ | ✅ | ✅ | ✅ | + ### Configuration Examples **Change port via command line:** @@ -650,6 +760,64 @@ cors: > ⚠️ **Security Note:** Using `["*"]` as `allowed_origins` is not recommended for production. When `allow_credentials` is `true`, wildcard origins will cause the application to fail to start with an exception. +### Authentication Configuration Examples + +**Enable authentication with write-only protection (recommended for development):** +```yaml +auth: + enabled: true + jwt_secret: "your_secret_key_at_least_32_chars_long" + jwt_algorithm: "HS256" + token_expiry_seconds: 3600 + refresh_token_expiry_seconds: 86400 + require_auth_for: "write" # GET requests work without auth + issuer: "ros2_medkit_gateway" + clients: + - "admin:admin_secret:admin" + - "operator:operator_secret:operator" + - "viewer:viewer_secret:viewer" +``` + +**Full protection (all endpoints require authentication):** +```yaml +auth: + enabled: true + jwt_secret: "your_production_secret_minimum_32_chars" + jwt_algorithm: "HS256" + token_expiry_seconds: 1800 # 30 minutes for tighter security + refresh_token_expiry_seconds: 43200 # 12 hours + require_auth_for: "all" # All endpoints require valid token + issuer: "production_gateway" + clients: + - "dashboard:dashboard_secret_key:admin" + - "monitoring:monitoring_secret:viewer" +``` + +**Usage with curl:** +```bash +# 1. Get access token +TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/authorize \ + -H "Content-Type: application/json" \ + -d '{"grant_type":"client_credentials","client_id":"admin","client_secret":"admin_secret"}' \ + | jq -r '.access_token') + +# 2. Use token for protected endpoints +curl http://localhost:8080/api/v1/components/temp_sensor/data \ + -H "Authorization: Bearer $TOKEN" + +# 3. Set a parameter (requires operator+ role) +curl -X PUT http://localhost:8080/api/v1/components/temp_sensor/configurations/rate \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"value": 5.0}' +``` + +> ⚠️ **Security Notes:** +> - Store `jwt_secret` securely and never commit it to version control +> - Use environment variables or secure secret management in production +> - RS256 algorithm requires additional setup with public/private key files +> - Client secrets should be generated using cryptographically secure random strings + ## Architecture ### Components diff --git a/src/ros2_medkit_gateway/config/gateway_params.yaml b/src/ros2_medkit_gateway/config/gateway_params.yaml index c360340..641694d 100644 --- a/src/ros2_medkit_gateway/config/gateway_params.yaml +++ b/src/ros2_medkit_gateway/config/gateway_params.yaml @@ -71,3 +71,57 @@ ros2_medkit_gateway: # - Returns metadata instantly for topics without publishers (no CLI timeout wait) # This significantly improves UX when many topics are idle (e.g., robot waiting for commands) # CLI is only used for publishing (ros2 topic pub) + + # Authentication Configuration (REQ_INTEROP_086, REQ_INTEROP_087) + # JWT-based authentication with Role-Based Access Control (RBAC) + auth: + # Enable/disable authentication + # Default: false (disabled for local development) + enabled: false + + # JWT signing secret (required when enabled) + # For HS256: The shared secret string + # For RS256: Path to the private key file (PEM format) + jwt_secret: "" + + # Path to public key file for RS256 (required for RS256, optional for HS256) + jwt_public_key: "" + + # JWT signing algorithm + # Options: "HS256" (symmetric, default), "RS256" (asymmetric) + jwt_algorithm: "HS256" + + # Access token validity period in seconds + # Default: 3600 (1 hour) + token_expiry_seconds: 3600 + + # Refresh token validity period in seconds + # Default: 86400 (24 hours) + # Must be >= token_expiry_seconds + refresh_token_expiry_seconds: 86400 + + # When to require authentication + # Options: + # - "none": No authentication required (auth endpoints still available) + # - "write": Auth required for write operations (POST, PUT, DELETE) + # - "all": Auth required for all operations + # Default: "write" + require_auth_for: "write" + + # JWT issuer claim + # Default: "ros2_medkit_gateway" + issuer: "ros2_medkit_gateway" + + # Pre-configured clients for authentication + # Format: "client_id:client_secret:role" + # Roles: viewer, operator, configurator, admin + # + # Role permissions: + # - viewer: Read-only access (GET on areas, components, data, faults) + # - operator: Viewer + trigger operations, acknowledge faults, publish data + # - configurator: Operator + modify/reset configurations + # - admin: Full access including auth management + # + # Example: + # clients: ["admin:admin_secret_123:admin", "viewer:viewer_pass:viewer"] + clients: [""] diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_config.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_config.hpp new file mode 100644 index 0000000..9a8150d --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_config.hpp @@ -0,0 +1,136 @@ +// Copyright 2025 bburda +// +// 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. + +#pragma once + +#include +#include +#include +#include + +namespace ros2_medkit_gateway { + +/** + * @brief JWT signing algorithm + */ +enum class JwtAlgorithm { + HS256, ///< HMAC-SHA256 (symmetric) + RS256 ///< RSA-SHA256 (asymmetric) +}; + +/** + * @brief User role for RBAC + * @verifies REQ_INTEROP_086 + */ +enum class UserRole { + VIEWER, ///< Read-only access: GET on areas, components, data, faults + OPERATOR, ///< Viewer + trigger operations, acknowledge faults + CONFIGURATOR, ///< Operator + modify configurations + ADMIN ///< Full access including auth management +}; + +/** + * @brief Authentication requirement level + */ +enum class AuthRequirement { + NONE, ///< No authentication required + WRITE, ///< Authentication required only for write operations + ALL ///< Authentication required for all operations +}; + +/** + * @brief Client credentials for authentication + */ +struct ClientCredentials { + std::string client_id; + std::string client_secret; + UserRole role{UserRole::VIEWER}; + bool enabled{true}; + + bool operator==(const ClientCredentials & other) const { + return client_id == other.client_id; + } +}; + +/** + * @brief Authentication configuration + * @verifies REQ_INTEROP_086, REQ_INTEROP_087 + */ +struct AuthConfig { + bool enabled{false}; ///< Whether authentication is enabled + std::string jwt_secret; ///< Secret for HS256 or path to private key for RS256 + std::string jwt_public_key; ///< Path to public key for RS256 (optional) + JwtAlgorithm jwt_algorithm{JwtAlgorithm::HS256}; ///< JWT signing algorithm + int token_expiry_seconds{3600}; ///< Access token validity (default: 1 hour) + int refresh_token_expiry_seconds{86400}; ///< Refresh token validity (default: 24 hours) + AuthRequirement require_auth_for{AuthRequirement::WRITE}; ///< When to require authentication + std::string issuer{"ros2_medkit_gateway"}; ///< JWT issuer claim + + // Pre-configured clients (for development/testing) + std::vector clients; + + // Role-to-permissions mapping (built-in defaults) + // Permissions are HTTP method + path patterns + static const std::unordered_map> & get_role_permissions(); +}; + +/** + * @brief Builder for AuthConfig with fluent interface + */ +class AuthConfigBuilder { + public: + AuthConfigBuilder & with_enabled(bool enabled); + AuthConfigBuilder & with_jwt_secret(const std::string & secret); + AuthConfigBuilder & with_jwt_public_key(const std::string & public_key); + AuthConfigBuilder & with_algorithm(JwtAlgorithm algorithm); + AuthConfigBuilder & with_token_expiry(int seconds); + AuthConfigBuilder & with_refresh_token_expiry(int seconds); + AuthConfigBuilder & with_require_auth_for(AuthRequirement requirement); + AuthConfigBuilder & with_issuer(const std::string & issuer); + AuthConfigBuilder & add_client(const std::string & client_id, const std::string & client_secret, UserRole role); + AuthConfig build(); + + private: + AuthConfig config_; +}; + +/** + * @brief Convert UserRole to string + */ +std::string role_to_string(UserRole role); + +/** + * @brief Convert string to UserRole + * @throws std::invalid_argument if string is not a valid role + */ +UserRole string_to_role(const std::string & role_str); + +/** + * @brief Convert JwtAlgorithm to string + */ +std::string algorithm_to_string(JwtAlgorithm algorithm); + +/** + * @brief Convert string to JwtAlgorithm + * @throws std::invalid_argument if string is not a valid algorithm + */ +JwtAlgorithm string_to_algorithm(const std::string & alg_str); + +/** + * @brief Convert string to AuthRequirement + * @throws std::invalid_argument if string is not a valid requirement + */ +AuthRequirement string_to_auth_requirement(const std::string & req_str); + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_manager.hpp new file mode 100644 index 0000000..c3356ab --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_manager.hpp @@ -0,0 +1,192 @@ +// Copyright 2025 bburda +// +// 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. + +#pragma once + +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/auth_config.hpp" +#include "ros2_medkit_gateway/auth_models.hpp" + +namespace ros2_medkit_gateway { + +/** + * @brief Manages authentication and authorization for the gateway + * + * Implements JWT-based authentication with RBAC (Role-Based Access Control). + * Supports both symmetric (HS256) and asymmetric (RS256) JWT signing. + * + * @verifies REQ_INTEROP_086, REQ_INTEROP_087 + */ +class AuthManager { + public: + /** + * @brief Construct AuthManager with configuration + * @param config Authentication configuration + */ + explicit AuthManager(const AuthConfig & config); + + ~AuthManager() = default; + + // Disable copy (contains mutex) + AuthManager(const AuthManager &) = delete; + AuthManager & operator=(const AuthManager &) = delete; + + // Enable move + AuthManager(AuthManager &&) = default; + AuthManager & operator=(AuthManager &&) = default; + + /** + * @brief Check if authentication is enabled + * @return true if auth is enabled + */ + bool is_enabled() const { + return config_.enabled; + } + + /** + * @brief Get the auth requirement level + * @return AuthRequirement level + */ + AuthRequirement get_requirement() const { + return config_.require_auth_for; + } + + /** + * @brief Authenticate client credentials and generate tokens + * @param client_id Client identifier + * @param client_secret Client secret + * @return TokenResponse on success, AuthErrorResponse on failure + */ + std::expected authenticate(const std::string & client_id, + const std::string & client_secret); + + /** + * @brief Refresh an access token using a refresh token + * @param refresh_token The refresh token + * @return TokenResponse on success, AuthErrorResponse on failure + */ + std::expected refresh_access_token(const std::string & refresh_token); + + /** + * @brief Validate a JWT access token + * @param token The JWT token string + * @return TokenValidationResult with claims if valid + */ + TokenValidationResult validate_token(const std::string & token) const; + + /** + * @brief Check if a role is authorized for a specific HTTP method and path + * @param role User role + * @param method HTTP method (GET, POST, PUT, DELETE) + * @param path Request path (e.g., /api/v1/components/engine/data) + * @return AuthorizationResult indicating if authorized + */ + AuthorizationResult check_authorization(UserRole role, const std::string & method, const std::string & path) const; + + /** + * @brief Check if authentication is required for a request + * @param method HTTP method + * @param path Request path + * @return true if authentication is required + */ + bool requires_authentication(const std::string & method, const std::string & path) const; + + /** + * @brief Revoke a refresh token + * @param refresh_token The refresh token to revoke + * @return true if revoked, false if not found + */ + bool revoke_refresh_token(const std::string & refresh_token); + + /** + * @brief Clean up expired refresh tokens + * @return Number of tokens cleaned up + */ + size_t cleanup_expired_tokens(); + + /** + * @brief Register a new client (for dynamic client registration) + * @param client_id Client identifier + * @param client_secret Client secret + * @param role Role to assign + * @return true if registered, false if client_id already exists + */ + bool register_client(const std::string & client_id, const std::string & client_secret, UserRole role); + + /** + * @brief Get client credentials by ID + * @param client_id Client identifier + * @return ClientCredentials if found + */ + std::optional get_client(const std::string & client_id) const; + + private: + /** + * @brief Generate a JWT token + * @param claims Token claims + * @return JWT token string + */ + std::string generate_jwt(const JwtClaims & claims) const; + + /** + * @brief Decode and verify a JWT token + * @param token JWT token string + * @return JwtClaims if valid + */ + std::expected decode_jwt(const std::string & token) const; + + /** + * @brief Generate a unique token ID + * @return UUID string + */ + static std::string generate_token_id(); + + /** + * @brief Check if a permission pattern matches a path + * @param pattern Permission pattern (may contain *) + * @param path Actual request path + * @return true if matches + */ + static bool matches_path(const std::string & pattern, const std::string & path); + + /** + * @brief Store a refresh token + * @param record Refresh token record + */ + void store_refresh_token(const RefreshTokenRecord & record); + + /** + * @brief Get a refresh token record by token ID + * @param token_id Token ID (jti) + * @return RefreshTokenRecord if found + */ + std::optional get_refresh_token(const std::string & token_id) const; + + AuthConfig config_; + + // Client credentials storage (thread-safe) + mutable std::mutex clients_mutex_; + std::unordered_map clients_; + + // Refresh token storage (thread-safe) + mutable std::mutex refresh_tokens_mutex_; + std::unordered_map refresh_tokens_; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_models.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_models.hpp new file mode 100644 index 0000000..817821c --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_models.hpp @@ -0,0 +1,226 @@ +// Copyright 2025 bburda +// +// 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. + +#pragma once + +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/auth_config.hpp" + +namespace ros2_medkit_gateway { + +using json = nlohmann::json; + +/** + * @brief JWT token claims + * @verifies REQ_INTEROP_087 + */ +struct JwtClaims { + std::string iss; ///< Issuer + std::string sub; ///< Subject (client_id) + int64_t exp{0}; ///< Expiration time (Unix timestamp) + int64_t iat{0}; ///< Issued at time (Unix timestamp) + std::string jti; ///< JWT ID (unique identifier) + UserRole role{UserRole::VIEWER}; ///< User role for RBAC + std::vector permissions; ///< Explicit permissions (optional) + std::optional refresh_token_id; ///< Associated refresh token ID (for access tokens) + + json to_json() const { + json j = {{"iss", iss}, {"sub", sub}, {"exp", exp}, {"iat", iat}, {"jti", jti}, {"role", role_to_string(role)}}; + + if (!permissions.empty()) { + j["permissions"] = permissions; + } + + if (refresh_token_id.has_value()) { + j["refresh_token_id"] = refresh_token_id.value(); + } + + return j; + } + + static JwtClaims from_json(const json & j) { + JwtClaims claims; + claims.iss = j.value("iss", ""); + claims.sub = j.value("sub", ""); + claims.exp = j.value("exp", int64_t{0}); + claims.iat = j.value("iat", int64_t{0}); + claims.jti = j.value("jti", ""); + + if (j.contains("role")) { + claims.role = string_to_role(j["role"].get()); + } + + if (j.contains("permissions")) { + claims.permissions = j["permissions"].get>(); + } + + if (j.contains("refresh_token_id")) { + claims.refresh_token_id = j["refresh_token_id"].get(); + } + + return claims; + } + + bool is_expired() const { + auto now = std::chrono::system_clock::now(); + auto exp_time = std::chrono::system_clock::from_time_t(exp); + return now > exp_time; + } +}; + +/** + * @brief Token response following OAuth2 format + * @verifies REQ_INTEROP_087 + */ +struct TokenResponse { + std::string access_token; + std::string token_type{"Bearer"}; + int expires_in{0}; ///< Seconds until expiration + std::optional refresh_token; + std::string scope; ///< Role-based scope + + json to_json() const { + json j = {{"access_token", access_token}, {"token_type", token_type}, {"expires_in", expires_in}, {"scope", scope}}; + + if (refresh_token.has_value()) { + j["refresh_token"] = refresh_token.value(); + } + + return j; + } +}; + +/** + * @brief Authorization request for /auth/authorize endpoint + * @verifies REQ_INTEROP_086 + */ +struct AuthorizeRequest { + std::string grant_type; ///< "client_credentials" or "refresh_token" + std::optional client_id; + std::optional client_secret; + std::optional refresh_token; + std::optional scope; ///< Requested scope/role + + static AuthorizeRequest from_json(const json & j) { + AuthorizeRequest req; + req.grant_type = j.value("grant_type", ""); + + if (j.contains("client_id")) { + req.client_id = j["client_id"].get(); + } + + if (j.contains("client_secret")) { + req.client_secret = j["client_secret"].get(); + } + + if (j.contains("refresh_token")) { + req.refresh_token = j["refresh_token"].get(); + } + + if (j.contains("scope")) { + req.scope = j["scope"].get(); + } + + return req; + } + + // Parse from URL-encoded form data (application/x-www-form-urlencoded) + static AuthorizeRequest from_form_data(const std::string & body); +}; + +/** + * @brief Result of token validation + */ +struct TokenValidationResult { + bool valid{false}; + std::string error; + std::optional claims; +}; + +/** + * @brief Result of authorization check + */ +struct AuthorizationResult { + bool authorized{false}; + std::string error; + std::optional required_permission; +}; + +/** + * @brief Refresh token storage record + */ +struct RefreshTokenRecord { + std::string token_id; ///< Unique ID (jti) + std::string client_id; ///< Associated client + UserRole role; ///< Role at time of issuance + int64_t issued_at{0}; ///< Unix timestamp + int64_t expires_at{0}; ///< Unix timestamp + bool revoked{false}; ///< Whether token has been revoked +}; + +/** + * @brief Error response for OAuth2 errors + */ +struct AuthErrorResponse { + std::string error; ///< Error code (e.g., "invalid_grant") + std::string error_description; ///< Human-readable description + + json to_json() const { + return {{"error", error}, {"error_description", error_description}}; + } + + // Standard OAuth2 error codes + static AuthErrorResponse invalid_request(const std::string & description) { + return {"invalid_request", description}; + } + + static AuthErrorResponse invalid_client(const std::string & description) { + return {"invalid_client", description}; + } + + static AuthErrorResponse invalid_grant(const std::string & description) { + return {"invalid_grant", description}; + } + + static AuthErrorResponse unauthorized_client(const std::string & description) { + return {"unauthorized_client", description}; + } + + static AuthErrorResponse unsupported_grant_type(const std::string & description) { + return {"unsupported_grant_type", description}; + } + + static AuthErrorResponse invalid_scope(const std::string & description) { + return {"invalid_scope", description}; + } + + static AuthErrorResponse access_denied(const std::string & description) { + return {"access_denied", description}; + } + + static AuthErrorResponse invalid_token(const std::string & description) { + return {"invalid_token", description}; + } + + static AuthErrorResponse insufficient_scope(const std::string & description) { + return {"insufficient_scope", description}; + } +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp index 865de09..a993ce1 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp @@ -23,6 +23,7 @@ #include #include +#include "ros2_medkit_gateway/auth_config.hpp" #include "ros2_medkit_gateway/config.hpp" #include "ros2_medkit_gateway/configuration_manager.hpp" #include "ros2_medkit_gateway/data_access_manager.hpp" @@ -84,6 +85,7 @@ class GatewayNode : public rclcpp::Node { int server_port_; int refresh_interval_ms_; CorsConfig cors_config_; + AuthConfig auth_config_; // Managers std::unique_ptr discovery_mgr_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp index 6221c53..01e8880 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp @@ -22,6 +22,8 @@ #include #include +#include "ros2_medkit_gateway/auth_config.hpp" +#include "ros2_medkit_gateway/auth_manager.hpp" #include "ros2_medkit_gateway/config.hpp" namespace ros2_medkit_gateway { @@ -30,7 +32,8 @@ class GatewayNode; class RESTServer { public: - RESTServer(GatewayNode * node, const std::string & host, int port, const CorsConfig & cors_config); + RESTServer(GatewayNode * node, const std::string & host, int port, const CorsConfig & cors_config, + const AuthConfig & auth_config); ~RESTServer(); void start(); @@ -65,16 +68,42 @@ class RESTServer { void handle_get_fault(const httplib::Request & req, httplib::Response & res); void handle_clear_fault(const httplib::Request & req, httplib::Response & res); + // Authentication endpoints (REQ_INTEROP_086, REQ_INTEROP_087) + void handle_auth_authorize(const httplib::Request & req, httplib::Response & res); + void handle_auth_token(const httplib::Request & req, httplib::Response & res); + void handle_auth_revoke(const httplib::Request & req, httplib::Response & res); + // Helper methods std::expected validate_entity_id(const std::string & entity_id) const; std::expected get_component_namespace_path(const std::string & component_id) const; void set_cors_headers(httplib::Response & res, const std::string & origin) const; bool is_origin_allowed(const std::string & origin) const; + // Authentication middleware + /** + * @brief Check if request is authenticated and authorized + * @param req HTTP request + * @param res HTTP response (set error on failure) + * @param method HTTP method + * @param path Request path + * @return true if authorized, false if denied (response already set) + */ + bool check_auth(const httplib::Request & req, httplib::Response & res, const std::string & method, + const std::string & path); + + /** + * @brief Extract Bearer token from Authorization header + * @param req HTTP request + * @return Token string if present and valid format + */ + std::optional extract_bearer_token(const httplib::Request & req) const; + GatewayNode * node_; std::string host_; int port_; CorsConfig cors_config_; + AuthConfig auth_config_; + std::unique_ptr auth_manager_; std::unique_ptr server_; }; diff --git a/src/ros2_medkit_gateway/package.xml b/src/ros2_medkit_gateway/package.xml index 25c3db1..5ed2a61 100644 --- a/src/ros2_medkit_gateway/package.xml +++ b/src/ros2_medkit_gateway/package.xml @@ -18,6 +18,8 @@ action_msgs nlohmann-json-dev libcpp-httplib-dev + + yaml_cpp_vendor ros2_medkit_msgs rosidl_runtime_py diff --git a/src/ros2_medkit_gateway/src/auth_config.cpp b/src/ros2_medkit_gateway/src/auth_config.cpp new file mode 100644 index 0000000..43e437c --- /dev/null +++ b/src/ros2_medkit_gateway/src/auth_config.cpp @@ -0,0 +1,274 @@ +// Copyright 2025 bburda +// +// 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. + +#include "ros2_medkit_gateway/auth_config.hpp" + +#include +#include + +namespace ros2_medkit_gateway { + +const std::unordered_map> & AuthConfig::get_role_permissions() { + // Static permission map - built once + // Format: "HTTP_METHOD:/path/pattern" where * is wildcard + // @verifies REQ_INTEROP_086 + static const std::unordered_map> permissions = { + {UserRole::VIEWER, + { + // Read-only access to all GET endpoints + "GET:/api/v1/health", + "GET:/api/v1/", + "GET:/api/v1/version-info", + "GET:/api/v1/areas", + "GET:/api/v1/areas/*", + "GET:/api/v1/components", + "GET:/api/v1/components/*/data", + "GET:/api/v1/components/*/data/*", + "GET:/api/v1/components/*/operations", + "GET:/api/v1/components/*/operations/*/status", + "GET:/api/v1/components/*/operations/*/result", + "GET:/api/v1/components/*/configurations", + "GET:/api/v1/components/*/configurations/*", + "GET:/api/v1/components/*/faults", + "GET:/api/v1/components/*/faults/*", + }}, + {UserRole::OPERATOR, + { + // Everything VIEWER can do, plus: + // Read-only access (inherited from VIEWER) + "GET:/api/v1/health", + "GET:/api/v1/", + "GET:/api/v1/version-info", + "GET:/api/v1/areas", + "GET:/api/v1/areas/*", + "GET:/api/v1/components", + "GET:/api/v1/components/*/data", + "GET:/api/v1/components/*/data/*", + "GET:/api/v1/components/*/operations", + "GET:/api/v1/components/*/operations/*/status", + "GET:/api/v1/components/*/operations/*/result", + "GET:/api/v1/components/*/configurations", + "GET:/api/v1/components/*/configurations/*", + "GET:/api/v1/components/*/faults", + "GET:/api/v1/components/*/faults/*", + // Trigger operations (POST) + "POST:/api/v1/components/*/operations/*", + // Cancel actions (DELETE on operations) + "DELETE:/api/v1/components/*/operations/*", + // Clear faults (DELETE on faults) + "DELETE:/api/v1/components/*/faults/*", + // Publish data to topics (PUT) + "PUT:/api/v1/components/*/data/*", + }}, + {UserRole::CONFIGURATOR, + { + // Everything OPERATOR can do, plus: + // Inherited from OPERATOR + "GET:/api/v1/health", + "GET:/api/v1/", + "GET:/api/v1/version-info", + "GET:/api/v1/areas", + "GET:/api/v1/areas/*", + "GET:/api/v1/components", + "GET:/api/v1/components/*/data", + "GET:/api/v1/components/*/data/*", + "GET:/api/v1/components/*/operations", + "GET:/api/v1/components/*/operations/*/status", + "GET:/api/v1/components/*/operations/*/result", + "GET:/api/v1/components/*/configurations", + "GET:/api/v1/components/*/configurations/*", + "GET:/api/v1/components/*/faults", + "GET:/api/v1/components/*/faults/*", + "POST:/api/v1/components/*/operations/*", + "DELETE:/api/v1/components/*/operations/*", + "DELETE:/api/v1/components/*/faults/*", + "PUT:/api/v1/components/*/data/*", + // Modify configurations (PUT) + "PUT:/api/v1/components/*/configurations/*", + // Reset configurations (DELETE) + "DELETE:/api/v1/components/*/configurations", + "DELETE:/api/v1/components/*/configurations/*", + }}, + {UserRole::ADMIN, + { + // Full access - all endpoints including auth + // ** matches any number of path segments + "GET:/api/v1/**", + "POST:/api/v1/**", + "PUT:/api/v1/**", + "DELETE:/api/v1/**", + // Auth endpoints are always accessible to admin + "POST:/api/v1/auth/authorize", + "POST:/api/v1/auth/token", + "POST:/api/v1/auth/revoke", + }}}; + return permissions; +} + +AuthConfigBuilder & AuthConfigBuilder::with_enabled(bool enabled) { + config_.enabled = enabled; + return *this; +} + +AuthConfigBuilder & AuthConfigBuilder::with_jwt_secret(const std::string & secret) { + config_.jwt_secret = secret; + return *this; +} + +AuthConfigBuilder & AuthConfigBuilder::with_jwt_public_key(const std::string & public_key) { + config_.jwt_public_key = public_key; + return *this; +} + +AuthConfigBuilder & AuthConfigBuilder::with_algorithm(JwtAlgorithm algorithm) { + config_.jwt_algorithm = algorithm; + return *this; +} + +AuthConfigBuilder & AuthConfigBuilder::with_token_expiry(int seconds) { + config_.token_expiry_seconds = seconds; + return *this; +} + +AuthConfigBuilder & AuthConfigBuilder::with_refresh_token_expiry(int seconds) { + config_.refresh_token_expiry_seconds = seconds; + return *this; +} + +AuthConfigBuilder & AuthConfigBuilder::with_require_auth_for(AuthRequirement requirement) { + config_.require_auth_for = requirement; + return *this; +} + +AuthConfigBuilder & AuthConfigBuilder::with_issuer(const std::string & issuer) { + config_.issuer = issuer; + return *this; +} + +AuthConfigBuilder & AuthConfigBuilder::add_client(const std::string & client_id, const std::string & client_secret, + UserRole role) { + ClientCredentials creds; + creds.client_id = client_id; + creds.client_secret = client_secret; + creds.role = role; + creds.enabled = true; + config_.clients.push_back(creds); + return *this; +} + +AuthConfig AuthConfigBuilder::build() { + // Validate configuration + if (config_.enabled) { + if (config_.jwt_secret.empty()) { + throw std::invalid_argument("JWT secret is required when authentication is enabled"); + } + + if (config_.token_expiry_seconds <= 0) { + throw std::invalid_argument("Token expiry must be positive"); + } + + if (config_.refresh_token_expiry_seconds <= 0) { + throw std::invalid_argument("Refresh token expiry must be positive"); + } + + if (config_.refresh_token_expiry_seconds < config_.token_expiry_seconds) { + throw std::invalid_argument("Refresh token expiry must be greater than or equal to token expiry"); + } + + // For RS256, validate key paths exist (actual file check done at runtime) + if (config_.jwt_algorithm == JwtAlgorithm::RS256) { + if (config_.jwt_public_key.empty()) { + throw std::invalid_argument("Public key path is required for RS256 algorithm"); + } + } + } + + return config_; +} + +std::string role_to_string(UserRole role) { + switch (role) { + case UserRole::VIEWER: + return "viewer"; + case UserRole::OPERATOR: + return "operator"; + case UserRole::CONFIGURATOR: + return "configurator"; + case UserRole::ADMIN: + return "admin"; + default: + return "unknown"; + } +} + +UserRole string_to_role(const std::string & role_str) { + std::string lower_role = role_str; + std::transform(lower_role.begin(), lower_role.end(), lower_role.begin(), ::tolower); + + if (lower_role == "viewer") { + return UserRole::VIEWER; + } + if (lower_role == "operator") { + return UserRole::OPERATOR; + } + if (lower_role == "configurator") { + return UserRole::CONFIGURATOR; + } + if (lower_role == "admin") { + return UserRole::ADMIN; + } + throw std::invalid_argument("Invalid role: " + role_str); +} + +std::string algorithm_to_string(JwtAlgorithm algorithm) { + switch (algorithm) { + case JwtAlgorithm::HS256: + return "HS256"; + case JwtAlgorithm::RS256: + return "RS256"; + default: + return "unknown"; + } +} + +JwtAlgorithm string_to_algorithm(const std::string & alg_str) { + std::string upper_alg = alg_str; + std::transform(upper_alg.begin(), upper_alg.end(), upper_alg.begin(), ::toupper); + + if (upper_alg == "HS256") { + return JwtAlgorithm::HS256; + } + if (upper_alg == "RS256") { + return JwtAlgorithm::RS256; + } + throw std::invalid_argument("Invalid algorithm: " + alg_str + ". Supported: HS256, RS256"); +} + +AuthRequirement string_to_auth_requirement(const std::string & req_str) { + std::string lower_req = req_str; + std::transform(lower_req.begin(), lower_req.end(), lower_req.begin(), ::tolower); + + if (lower_req == "none") { + return AuthRequirement::NONE; + } + if (lower_req == "write") { + return AuthRequirement::WRITE; + } + if (lower_req == "all") { + return AuthRequirement::ALL; + } + throw std::invalid_argument("Invalid auth requirement: " + req_str + ". Supported: none, write, all"); +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/auth_manager.cpp b/src/ros2_medkit_gateway/src/auth_manager.cpp new file mode 100644 index 0000000..198652d --- /dev/null +++ b/src/ros2_medkit_gateway/src/auth_manager.cpp @@ -0,0 +1,543 @@ +// Copyright 2025 bburda +// +// 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. + +#include "ros2_medkit_gateway/auth_manager.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace ros2_medkit_gateway { + +// Helper to read file contents +static std::string read_file_contents(const std::string & path) { + std::ifstream file(path); + if (!file.is_open()) { + throw std::runtime_error("Failed to open file: " + path); + } + std::stringstream buffer; + buffer << file.rdbuf(); + return buffer.str(); +} + +AuthManager::AuthManager(const AuthConfig & config) : config_(config) { + // Initialize clients from config + for (const auto & client : config_.clients) { + clients_[client.client_id] = client; + } +} + +std::expected AuthManager::authenticate(const std::string & client_id, + const std::string & client_secret) { + // Find client + std::lock_guard lock(clients_mutex_); + auto it = clients_.find(client_id); + if (it == clients_.end()) { + return std::unexpected(AuthErrorResponse::invalid_client("Unknown client_id")); + } + + const auto & client = it->second; + + // Check if client is enabled + if (!client.enabled) { + return std::unexpected(AuthErrorResponse::invalid_client("Client is disabled")); + } + + // Verify secret + if (client.client_secret != client_secret) { + return std::unexpected(AuthErrorResponse::invalid_client("Invalid client_secret")); + } + + // Generate tokens + auto now = std::chrono::system_clock::now(); + auto now_ts = std::chrono::duration_cast(now.time_since_epoch()).count(); + + // Generate refresh token first + std::string refresh_token_id = generate_token_id(); + JwtClaims refresh_claims; + refresh_claims.iss = config_.issuer; + refresh_claims.sub = client_id; + refresh_claims.iat = now_ts; + refresh_claims.exp = now_ts + config_.refresh_token_expiry_seconds; + refresh_claims.jti = refresh_token_id; + refresh_claims.role = client.role; + + std::string refresh_token = generate_jwt(refresh_claims); + + // Store refresh token record + RefreshTokenRecord refresh_record; + refresh_record.token_id = refresh_token_id; + refresh_record.client_id = client_id; + refresh_record.role = client.role; + refresh_record.issued_at = now_ts; + refresh_record.expires_at = refresh_claims.exp; + refresh_record.revoked = false; + store_refresh_token(refresh_record); + + // Generate access token + JwtClaims access_claims; + access_claims.iss = config_.issuer; + access_claims.sub = client_id; + access_claims.iat = now_ts; + access_claims.exp = now_ts + config_.token_expiry_seconds; + access_claims.jti = generate_token_id(); + access_claims.role = client.role; + access_claims.refresh_token_id = refresh_token_id; + + std::string access_token = generate_jwt(access_claims); + + // Build response + TokenResponse response; + response.access_token = access_token; + response.token_type = "Bearer"; + response.expires_in = config_.token_expiry_seconds; + response.refresh_token = refresh_token; + response.scope = role_to_string(client.role); + + return response; +} + +std::expected AuthManager::refresh_access_token(const std::string & refresh_token) { + // Decode and validate refresh token + auto decode_result = decode_jwt(refresh_token); + if (!decode_result) { + return std::unexpected(AuthErrorResponse::invalid_grant(decode_result.error())); + } + + const auto & claims = decode_result.value(); + + // Check if refresh token exists and is not revoked + auto record = get_refresh_token(claims.jti); + if (!record.has_value()) { + return std::unexpected(AuthErrorResponse::invalid_grant("Refresh token not found")); + } + + if (record->revoked) { + return std::unexpected(AuthErrorResponse::invalid_grant("Refresh token has been revoked")); + } + + // Check expiration + if (claims.is_expired()) { + return std::unexpected(AuthErrorResponse::invalid_grant("Refresh token has expired")); + } + + // Get client to check if still enabled + auto client = get_client(claims.sub); + if (!client.has_value()) { + return std::unexpected(AuthErrorResponse::invalid_grant("Client no longer exists")); + } + + if (!client->enabled) { + return std::unexpected(AuthErrorResponse::invalid_grant("Client is disabled")); + } + + // Generate new access token + auto now = std::chrono::system_clock::now(); + auto now_ts = std::chrono::duration_cast(now.time_since_epoch()).count(); + + JwtClaims access_claims; + access_claims.iss = config_.issuer; + access_claims.sub = claims.sub; + access_claims.iat = now_ts; + access_claims.exp = now_ts + config_.token_expiry_seconds; + access_claims.jti = generate_token_id(); + access_claims.role = record->role; // Use role from refresh token record + access_claims.refresh_token_id = claims.jti; + + std::string access_token = generate_jwt(access_claims); + + // Build response (no new refresh token on refresh) + TokenResponse response; + response.access_token = access_token; + response.token_type = "Bearer"; + response.expires_in = config_.token_expiry_seconds; + response.scope = role_to_string(record->role); + + return response; +} + +TokenValidationResult AuthManager::validate_token(const std::string & token) const { + TokenValidationResult result; + + auto decode_result = decode_jwt(token); + if (!decode_result) { + result.valid = false; + result.error = decode_result.error(); + return result; + } + + const auto & claims = decode_result.value(); + + // Check expiration + if (claims.is_expired()) { + result.valid = false; + result.error = "Token has expired"; + return result; + } + + // Check if associated refresh token is revoked (for access tokens) + if (claims.refresh_token_id.has_value()) { + auto record = get_refresh_token(claims.refresh_token_id.value()); + if (record.has_value() && record->revoked) { + result.valid = false; + result.error = "Associated refresh token has been revoked"; + return result; + } + } + + result.valid = true; + result.claims = claims; + return result; +} + +AuthorizationResult AuthManager::check_authorization(UserRole role, const std::string & method, + const std::string & path) const { + AuthorizationResult result; + + const auto & role_permissions = AuthConfig::get_role_permissions(); + auto it = role_permissions.find(role); + if (it == role_permissions.end()) { + result.authorized = false; + result.error = "Unknown role"; + return result; + } + + const auto & permissions = it->second; + std::string permission_key = method + ":" + path; + + // Check exact match first + if (permissions.count(permission_key) > 0) { + result.authorized = true; + return result; + } + + // Check wildcard patterns + for (const auto & pattern : permissions) { + // Extract method and path pattern + size_t colon_pos = pattern.find(':'); + if (colon_pos == std::string::npos) { + continue; + } + + std::string pattern_method = pattern.substr(0, colon_pos); + std::string pattern_path = pattern.substr(colon_pos + 1); + + // Method must match exactly + if (pattern_method != method) { + continue; + } + + // Check path pattern + if (matches_path(pattern_path, path)) { + result.authorized = true; + return result; + } + } + + result.authorized = false; + result.error = "Insufficient permissions"; + result.required_permission = permission_key; + return result; +} + +bool AuthManager::requires_authentication(const std::string & method, const std::string & path) const { + if (!config_.enabled) { + return false; + } + + // Auth endpoints are always accessible without auth (to allow login) + if (path.find("/api/v1/auth/") == 0) { + return false; + } + + switch (config_.require_auth_for) { + case AuthRequirement::NONE: + return false; + + case AuthRequirement::WRITE: + // Require auth for write operations + return method == "POST" || method == "PUT" || method == "DELETE" || method == "PATCH"; + + case AuthRequirement::ALL: + return true; + + default: + return false; + } +} + +bool AuthManager::revoke_refresh_token(const std::string & refresh_token) { + // Decode token to get the jti + auto decode_result = decode_jwt(refresh_token); + if (!decode_result) { + return false; + } + + const auto & claims = decode_result.value(); + + std::lock_guard lock(refresh_tokens_mutex_); + auto it = refresh_tokens_.find(claims.jti); + if (it == refresh_tokens_.end()) { + return false; + } + + it->second.revoked = true; + return true; +} + +size_t AuthManager::cleanup_expired_tokens() { + auto now = std::chrono::system_clock::now(); + auto now_ts = std::chrono::duration_cast(now.time_since_epoch()).count(); + + std::lock_guard lock(refresh_tokens_mutex_); + size_t count = 0; + + for (auto it = refresh_tokens_.begin(); it != refresh_tokens_.end();) { + if (it->second.expires_at < now_ts) { + it = refresh_tokens_.erase(it); + ++count; + } else { + ++it; + } + } + + return count; +} + +bool AuthManager::register_client(const std::string & client_id, const std::string & client_secret, UserRole role) { + std::lock_guard lock(clients_mutex_); + + if (clients_.count(client_id) > 0) { + return false; + } + + ClientCredentials creds; + creds.client_id = client_id; + creds.client_secret = client_secret; + creds.role = role; + creds.enabled = true; + + clients_[client_id] = creds; + return true; +} + +std::optional AuthManager::get_client(const std::string & client_id) const { + std::lock_guard lock(clients_mutex_); + auto it = clients_.find(client_id); + if (it == clients_.end()) { + return std::nullopt; + } + return it->second; +} + +std::string AuthManager::generate_jwt(const JwtClaims & claims) const { + auto builder = jwt::create() + .set_issuer(claims.iss) + .set_subject(claims.sub) + .set_issued_at(std::chrono::system_clock::from_time_t(claims.iat)) + .set_expires_at(std::chrono::system_clock::from_time_t(claims.exp)) + .set_id(claims.jti) + .set_payload_claim("role", jwt::claim(role_to_string(claims.role))); + + if (!claims.permissions.empty()) { + // Convert vector to set for jwt-cpp + std::set perms_set(claims.permissions.begin(), claims.permissions.end()); + builder.set_payload_claim("permissions", jwt::claim(perms_set)); + } + + if (claims.refresh_token_id.has_value()) { + builder.set_payload_claim("refresh_token_id", jwt::claim(claims.refresh_token_id.value())); + } + + // Sign based on algorithm + switch (config_.jwt_algorithm) { + case JwtAlgorithm::HS256: + return builder.sign(jwt::algorithm::hs256{config_.jwt_secret}); + + case JwtAlgorithm::RS256: { + std::string private_key = read_file_contents(config_.jwt_secret); + return builder.sign(jwt::algorithm::rs256("", private_key, "", "")); + } + + default: + throw std::runtime_error("Unsupported JWT algorithm"); + } +} + +std::expected AuthManager::decode_jwt(const std::string & token) const { + try { + // Decode token first + auto decoded = jwt::decode(token); + + // Verify signature + try { + switch (config_.jwt_algorithm) { + case JwtAlgorithm::HS256: { + auto verifier = + jwt::verify().allow_algorithm(jwt::algorithm::hs256{config_.jwt_secret}).with_issuer(config_.issuer); + verifier.verify(decoded); + break; + } + + case JwtAlgorithm::RS256: { + std::string public_key = read_file_contents(config_.jwt_public_key); + auto verifier = + jwt::verify().allow_algorithm(jwt::algorithm::rs256(public_key, "", "", "")).with_issuer(config_.issuer); + verifier.verify(decoded); + break; + } + + default: + return std::unexpected("Unsupported JWT algorithm"); + } + } catch (const jwt::error::token_verification_exception & e) { + return std::unexpected("Token verification failed: " + std::string(e.what())); + } + + // Extract claims + JwtClaims claims; + claims.iss = decoded.get_issuer(); + claims.sub = decoded.get_subject(); + claims.jti = decoded.get_id(); + + auto exp_claim = decoded.get_expires_at(); + claims.exp = std::chrono::duration_cast(exp_claim.time_since_epoch()).count(); + + auto iat_claim = decoded.get_issued_at(); + claims.iat = std::chrono::duration_cast(iat_claim.time_since_epoch()).count(); + + if (decoded.has_payload_claim("role")) { + claims.role = string_to_role(decoded.get_payload_claim("role").as_string()); + } + + if (decoded.has_payload_claim("permissions")) { + auto perms = decoded.get_payload_claim("permissions").as_array(); + for (const auto & p : perms) { + claims.permissions.push_back(p.get()); + } + } + + if (decoded.has_payload_claim("refresh_token_id")) { + claims.refresh_token_id = decoded.get_payload_claim("refresh_token_id").as_string(); + } + + return claims; + } catch (const std::exception & e) { + return std::unexpected("JWT decode error: " + std::string(e.what())); + } +} + +std::string AuthManager::generate_token_id() { + // Generate UUID-like string + static std::random_device rd; + static std::mt19937_64 gen(rd()); + static std::uniform_int_distribution dis; + + std::stringstream ss; + ss << std::hex << std::setfill('0'); + + uint64_t part1 = dis(gen); + uint64_t part2 = dis(gen); + + ss << std::setw(8) << (part1 >> 32) << "-"; + ss << std::setw(4) << ((part1 >> 16) & 0xFFFF) << "-"; + ss << std::setw(4) << (part1 & 0xFFFF) << "-"; + ss << std::setw(4) << (part2 >> 48) << "-"; + ss << std::setw(12) << (part2 & 0xFFFFFFFFFFFF); + + return ss.str(); +} + +bool AuthManager::matches_path(const std::string & pattern, const std::string & path) { + // Simple wildcard matching + // * matches any single path segment + // ** matches any number of path segments (including none) + // Pattern: /api/v1/components/*/data + // Path: /api/v1/components/engine/data + // Pattern: /api/v1/** + // Path: /api/v1/components/engine/data/temperature (matches) + + if (pattern == path) { + return true; + } + + // Convert pattern to regex + std::string regex_pattern; + regex_pattern.reserve(pattern.size() * 2); + + for (size_t i = 0; i < pattern.size(); ++i) { + char c = pattern[i]; + if (c == '*') { + // Check for ** (multi-segment wildcard) + if (i + 1 < pattern.size() && pattern[i + 1] == '*') { + regex_pattern += ".*"; // Match anything including slashes + ++i; // Skip the second * + } else { + regex_pattern += "[^/]+"; // Match any non-slash characters (single segment) + } + } else { + switch (c) { + case '.': + case '[': + case ']': + case '(': + case ')': + case '{': + case '}': + case '\\': + case '^': + case '$': + case '|': + case '?': + case '+': + regex_pattern += '\\'; + regex_pattern += c; + break; + default: + regex_pattern += c; + } + } + } + + // Anchor the pattern + regex_pattern = "^" + regex_pattern + "$"; + + try { + std::regex re(regex_pattern); + return std::regex_match(path, re); + } catch (const std::regex_error &) { + return false; + } +} + +void AuthManager::store_refresh_token(const RefreshTokenRecord & record) { + std::lock_guard lock(refresh_tokens_mutex_); + refresh_tokens_[record.token_id] = record; +} + +std::optional AuthManager::get_refresh_token(const std::string & token_id) const { + std::lock_guard lock(refresh_tokens_mutex_); + auto it = refresh_tokens_.find(token_id); + if (it == refresh_tokens_.end()) { + return std::nullopt; + } + return it->second; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/auth_models.cpp b/src/ros2_medkit_gateway/src/auth_models.cpp new file mode 100644 index 0000000..87d38a7 --- /dev/null +++ b/src/ros2_medkit_gateway/src/auth_models.cpp @@ -0,0 +1,71 @@ +// Copyright 2025 bburda +// +// 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. + +#include "ros2_medkit_gateway/auth_models.hpp" + +#include + +namespace ros2_medkit_gateway { + +// URL-decode a string (handle %XX encoding) +static std::string url_decode(const std::string & encoded) { + std::string decoded; + decoded.reserve(encoded.size()); + + for (size_t i = 0; i < encoded.size(); ++i) { + if (encoded[i] == '%' && i + 2 < encoded.size()) { + int hex_val = 0; + std::istringstream hex_stream(encoded.substr(i + 1, 2)); + hex_stream >> std::hex >> hex_val; + decoded += static_cast(hex_val); + i += 2; + } else if (encoded[i] == '+') { + decoded += ' '; + } else { + decoded += encoded[i]; + } + } + + return decoded; +} + +AuthorizeRequest AuthorizeRequest::from_form_data(const std::string & body) { + AuthorizeRequest req; + std::istringstream stream(body); + std::string pair; + + while (std::getline(stream, pair, '&')) { + size_t eq_pos = pair.find('='); + if (eq_pos != std::string::npos) { + std::string key = url_decode(pair.substr(0, eq_pos)); + std::string value = url_decode(pair.substr(eq_pos + 1)); + + if (key == "grant_type") { + req.grant_type = value; + } else if (key == "client_id") { + req.client_id = value; + } else if (key == "client_secret") { + req.client_secret = value; + } else if (key == "refresh_token") { + req.refresh_token = value; + } else if (key == "scope") { + req.scope = value; + } + } + } + + return req; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index a94b4f6..aa0c5e0 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -33,6 +33,17 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { declare_parameter("cors.allow_credentials", false); declare_parameter("cors.max_age_seconds", 86400); + // Authentication parameters (REQ_INTEROP_086, REQ_INTEROP_087) + declare_parameter("auth.enabled", false); + declare_parameter("auth.jwt_secret", ""); + declare_parameter("auth.jwt_public_key", ""); + declare_parameter("auth.jwt_algorithm", "HS256"); + declare_parameter("auth.token_expiry_seconds", 3600); + declare_parameter("auth.refresh_token_expiry_seconds", 86400); + declare_parameter("auth.require_auth_for", "write"); + declare_parameter("auth.issuer", "ros2_medkit_gateway"); + declare_parameter("auth.clients", std::vector{}); + // Get parameter values server_host_ = get_parameter("server.host").as_string(); server_port_ = static_cast(get_parameter("server.port").as_int()); @@ -100,6 +111,61 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { RCLCPP_INFO(get_logger(), "CORS: disabled (no configuration provided)"); } + // Build Authentication configuration (REQ_INTEROP_086, REQ_INTEROP_087) + bool auth_enabled = get_parameter("auth.enabled").as_bool(); + if (auth_enabled) { + try { + AuthConfigBuilder auth_builder; + auth_builder.with_enabled(true) + .with_jwt_secret(get_parameter("auth.jwt_secret").as_string()) + .with_jwt_public_key(get_parameter("auth.jwt_public_key").as_string()) + .with_algorithm(string_to_algorithm(get_parameter("auth.jwt_algorithm").as_string())) + .with_token_expiry(static_cast(get_parameter("auth.token_expiry_seconds").as_int())) + .with_refresh_token_expiry(static_cast(get_parameter("auth.refresh_token_expiry_seconds").as_int())) + .with_require_auth_for(string_to_auth_requirement(get_parameter("auth.require_auth_for").as_string())) + .with_issuer(get_parameter("auth.issuer").as_string()); + + // Parse clients from configuration + // Format: "client_id:client_secret:role" (e.g., "admin:secret123:admin") + auto clients = get_parameter("auth.clients").as_string_array(); + for (const auto & client_str : clients) { + if (client_str.empty()) { + continue; + } + // Parse "client_id:client_secret:role" + size_t first_colon = client_str.find(':'); + size_t last_colon = client_str.rfind(':'); + if (first_colon != std::string::npos && last_colon != std::string::npos && first_colon != last_colon) { + std::string client_id = client_str.substr(0, first_colon); + std::string client_secret = client_str.substr(first_colon + 1, last_colon - first_colon - 1); + std::string role_str = client_str.substr(last_colon + 1); + try { + UserRole role = string_to_role(role_str); + auth_builder.add_client(client_id, client_secret, role); + RCLCPP_INFO(get_logger(), "Registered client '%s' with role '%s'", client_id.c_str(), role_str.c_str()); + } catch (const std::exception & e) { + RCLCPP_WARN(get_logger(), "Invalid role '%s' for client '%s': %s", role_str.c_str(), client_id.c_str(), + e.what()); + } + } else { + RCLCPP_WARN(get_logger(), "Invalid client format: '%s'. Expected 'client_id:client_secret:role'", + client_str.c_str()); + } + } + + auth_config_ = auth_builder.build(); + RCLCPP_INFO(get_logger(), "Authentication enabled - algorithm: %s, require_auth_for: %s", + algorithm_to_string(auth_config_.jwt_algorithm).c_str(), + get_parameter("auth.require_auth_for").as_string().c_str()); + } catch (const std::exception & e) { + RCLCPP_ERROR(get_logger(), "Invalid authentication configuration: %s. Authentication disabled.", e.what()); + auth_config_ = AuthConfig{}; // Disabled + } + } else { + RCLCPP_INFO(get_logger(), "Authentication: disabled"); + auth_config_ = AuthConfig{}; + } + // Initialize managers discovery_mgr_ = std::make_unique(this); data_access_mgr_ = std::make_unique(this); @@ -125,8 +191,8 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { operation_mgr_->cleanup_old_goals(std::chrono::seconds(300)); }); - // Start REST server with configured host, port and CORS - rest_server_ = std::make_unique(this, server_host_, server_port_, cors_config_); + // Start REST server with configured host, port, CORS and auth + rest_server_ = std::make_unique(this, server_host_, server_port_, cors_config_, auth_config_); start_rest_server(); RCLCPP_INFO(get_logger(), "ROS 2 Medkit Gateway ready on %s:%d", server_host_.c_str(), server_port_); diff --git a/src/ros2_medkit_gateway/src/rest_server.cpp b/src/ros2_medkit_gateway/src/rest_server.cpp index 6ce81da..da50d14 100644 --- a/src/ros2_medkit_gateway/src/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/rest_server.cpp @@ -20,6 +20,7 @@ #include #include +#include "ros2_medkit_gateway/auth_models.hpp" #include "ros2_medkit_gateway/exceptions.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" @@ -36,22 +37,41 @@ inline std::string api_path(const std::string & endpoint) { return std::string(API_BASE_PATH) + endpoint; } -RESTServer::RESTServer(GatewayNode * node, const std::string & host, int port, const CorsConfig & cors_config) - : node_(node), host_(host), port_(port), cors_config_(cors_config) { +RESTServer::RESTServer(GatewayNode * node, const std::string & host, int port, const CorsConfig & cors_config, + const AuthConfig & auth_config) + : node_(node), host_(host), port_(port), cors_config_(cors_config), auth_config_(auth_config) { server_ = std::make_unique(); - // Set up pre-routing handler for CORS (only if enabled) - if (cors_config_.enabled) { - server_->set_pre_routing_handler([this](const httplib::Request & req, httplib::Response & res) { + // Initialize auth manager if auth is enabled + if (auth_config_.enabled) { + auth_manager_ = std::make_unique(auth_config_); + RCLCPP_INFO(rclcpp::get_logger("rest_server"), "Authentication enabled - algorithm: %s, require_auth_for: %s", + algorithm_to_string(auth_config_.jwt_algorithm).c_str(), + auth_config_.require_auth_for == AuthRequirement::NONE ? "none" + : auth_config_.require_auth_for == AuthRequirement::WRITE ? "write" + : "all"); + } + + // Set up pre-routing handler for CORS and Authentication + // This handler runs before any route handler + server_->set_pre_routing_handler([this](const httplib::Request & req, httplib::Response & res) { + // Handle CORS if enabled + if (cors_config_.enabled) { std::string origin = req.get_header_value("Origin"); bool origin_allowed = !origin.empty() && is_origin_allowed(origin); if (origin_allowed) { set_cors_headers(res, origin); + // Add Authorization header to allowed headers for CORS + if (auth_config_.enabled) { + std::string current_headers = res.get_header_value("Access-Control-Allow-Headers"); + if (!current_headers.empty() && current_headers.find("Authorization") == std::string::npos) { + res.set_header("Access-Control-Allow-Headers", current_headers + ", Authorization"); + } + } } // Handle preflight OPTIONS requests - // Return 204 for allowed origins, 403 for disallowed (prevents endpoint discovery) if (req.method == "OPTIONS") { if (origin_allowed) { res.set_header("Access-Control-Max-Age", std::to_string(cors_config_.max_age_seconds)); @@ -61,9 +81,22 @@ RESTServer::RESTServer(GatewayNode * node, const std::string & host, int port, c } return httplib::Server::HandlerResponse::Handled; } - return httplib::Server::HandlerResponse::Unhandled; - }); - } + } + + // Handle Authentication if enabled + if (auth_config_.enabled && auth_manager_) { + // Extract path from request + std::string path = req.path; + + // Check authentication + if (!check_auth(req, res, req.method, path)) { + // Response already set by check_auth + return httplib::Server::HandlerResponse::Handled; + } + } + + return httplib::Server::HandlerResponse::Unhandled; + }); setup_routes(); } @@ -203,6 +236,22 @@ void RESTServer::setup_routes() { [this](const httplib::Request & req, httplib::Response & res) { handle_clear_fault(req, res); }); + + // Authentication endpoints (REQ_INTEROP_086, REQ_INTEROP_087) + // POST /auth/authorize - Authenticate and get tokens (client_credentials grant) + server_->Post(api_path("/auth/authorize").c_str(), [this](const httplib::Request & req, httplib::Response & res) { + handle_auth_authorize(req, res); + }); + + // POST /auth/token - Refresh access token + server_->Post(api_path("/auth/token").c_str(), [this](const httplib::Request & req, httplib::Response & res) { + handle_auth_token(req, res); + }); + + // POST /auth/revoke - Revoke a refresh token + server_->Post(api_path("/auth/revoke").c_str(), [this](const httplib::Request & req, httplib::Response & res) { + handle_auth_revoke(req, res); + }); } void RESTServer::start() { @@ -285,33 +334,60 @@ void RESTServer::handle_root(const httplib::Request & req, httplib::Response & r (void)req; // Unused parameter try { + json endpoints = json::array({ + "GET /api/v1/health", + "GET /api/v1/version-info", + "GET /api/v1/areas", + "GET /api/v1/components", + "GET /api/v1/areas/{area_id}/components", + "GET /api/v1/components/{component_id}/data", + "GET /api/v1/components/{component_id}/data/{topic_name}", + "PUT /api/v1/components/{component_id}/data/{topic_name}", + "GET /api/v1/components/{component_id}/operations", + "POST /api/v1/components/{component_id}/operations/{operation_name}", + "GET /api/v1/components/{component_id}/operations/{operation_name}/status", + "GET /api/v1/components/{component_id}/operations/{operation_name}/result", + "DELETE /api/v1/components/{component_id}/operations/{operation_name}", + "GET /api/v1/components/{component_id}/configurations", + "GET /api/v1/components/{component_id}/configurations/{param_name}", + "PUT /api/v1/components/{component_id}/configurations/{param_name}", + "GET /api/v1/components/{component_id}/faults", + "GET /api/v1/components/{component_id}/faults/{fault_code}", + "DELETE /api/v1/components/{component_id}/faults/{fault_code}", + }); + + // Add auth endpoints if auth is enabled + if (auth_config_.enabled) { + endpoints.push_back("POST /api/v1/auth/authorize"); + endpoints.push_back("POST /api/v1/auth/token"); + endpoints.push_back("POST /api/v1/auth/revoke"); + } + + json capabilities = { + {"discovery", true}, + {"data_access", true}, + {"operations", true}, + {"async_actions", true}, + {"configurations", true}, + {"faults", true}, + {"authentication", auth_config_.enabled}, + }; + json response = { - {"name", "ROS 2 Medkit Gateway"}, - {"version", "0.1.0"}, - {"api_base", API_BASE_PATH}, - {"endpoints", - json::array({"GET /api/v1/health", "GET /api/v1/version-info", "GET /api/v1/areas", "GET /api/v1/components", - "GET /api/v1/areas/{area_id}/components", "GET /api/v1/components/{component_id}/data", - "GET /api/v1/components/{component_id}/data/{topic_name}", - "PUT /api/v1/components/{component_id}/data/{topic_name}", - "GET /api/v1/components/{component_id}/operations", - "POST /api/v1/components/{component_id}/operations/{operation_name}", - "GET /api/v1/components/{component_id}/operations/{operation_name}/status", - "GET /api/v1/components/{component_id}/operations/{operation_name}/result", - "DELETE /api/v1/components/{component_id}/operations/{operation_name}", - "GET /api/v1/components/{component_id}/configurations", - "GET /api/v1/components/{component_id}/configurations/{param_name}", - "PUT /api/v1/components/{component_id}/configurations/{param_name}", - "GET /api/v1/components/{component_id}/faults", - "GET /api/v1/components/{component_id}/faults/{fault_code}", - "DELETE /api/v1/components/{component_id}/faults/{fault_code}"})}, - {"capabilities", - {{"discovery", true}, - {"data_access", true}, - {"operations", true}, - {"async_actions", true}, - {"configurations", true}, - {"faults", true}}}}; + {"name", "ROS 2 Medkit Gateway"}, {"version", "0.1.0"}, {"api_base", API_BASE_PATH}, + {"endpoints", endpoints}, {"capabilities", capabilities}, + }; + + // Add auth info if enabled + if (auth_config_.enabled) { + response["auth"] = { + {"enabled", true}, + {"algorithm", algorithm_to_string(auth_config_.jwt_algorithm)}, + {"require_auth_for", auth_config_.require_auth_for == AuthRequirement::NONE ? "none" + : auth_config_.require_auth_for == AuthRequirement::WRITE ? "write" + : "all"}, + }; + } res.set_content(response.dump(2), "application/json"); } catch (const std::exception & e) { @@ -2001,4 +2077,259 @@ bool RESTServer::is_origin_allowed(const std::string & origin) const { return false; } +// Authentication handlers (REQ_INTEROP_086, REQ_INTEROP_087) + +void RESTServer::handle_auth_authorize(const httplib::Request & req, httplib::Response & res) { + try { + if (!auth_config_.enabled) { + res.status = StatusCode::NotFound_404; + res.set_content(json{{"error", "Authentication is not enabled"}}.dump(2), "application/json"); + return; + } + + // Parse request - support both JSON and form-urlencoded + AuthorizeRequest auth_req; + std::string content_type = req.get_header_value("Content-Type"); + + if (content_type.find("application/json") != std::string::npos) { + try { + json body = json::parse(req.body); + auth_req = AuthorizeRequest::from_json(body); + } catch (const json::parse_error & e) { + res.status = StatusCode::BadRequest_400; + res.set_content(AuthErrorResponse::invalid_request("Invalid JSON: " + std::string(e.what())).to_json().dump(2), + "application/json"); + return; + } + } else if (content_type.find("application/x-www-form-urlencoded") != std::string::npos) { + auth_req = AuthorizeRequest::from_form_data(req.body); + } else { + res.status = StatusCode::BadRequest_400; + res.set_content(AuthErrorResponse::invalid_request( + "Content-Type must be application/json or application/x-www-form-urlencoded") + .to_json() + .dump(2), + "application/json"); + return; + } + + // Validate grant_type + if (auth_req.grant_type != "client_credentials") { + res.status = StatusCode::BadRequest_400; + res.set_content(AuthErrorResponse::unsupported_grant_type("Only 'client_credentials' grant type is supported") + .to_json() + .dump(2), + "application/json"); + return; + } + + // Validate required fields + if (!auth_req.client_id.has_value() || auth_req.client_id->empty()) { + res.status = StatusCode::BadRequest_400; + res.set_content(AuthErrorResponse::invalid_request("client_id is required").to_json().dump(2), + "application/json"); + return; + } + + if (!auth_req.client_secret.has_value() || auth_req.client_secret->empty()) { + res.status = StatusCode::BadRequest_400; + res.set_content(AuthErrorResponse::invalid_request("client_secret is required").to_json().dump(2), + "application/json"); + return; + } + + // Authenticate + auto result = auth_manager_->authenticate(auth_req.client_id.value(), auth_req.client_secret.value()); + + if (result) { + res.set_content(result->to_json().dump(2), "application/json"); + } else { + res.status = StatusCode::Unauthorized_401; + res.set_content(result.error().to_json().dump(2), "application/json"); + } + } catch (const std::exception & e) { + res.status = StatusCode::InternalServerError_500; + res.set_content(json{{"error", "Internal server error"}, {"details", e.what()}}.dump(2), "application/json"); + RCLCPP_ERROR(rclcpp::get_logger("rest_server"), "Error in handle_auth_authorize: %s", e.what()); + } +} + +void RESTServer::handle_auth_token(const httplib::Request & req, httplib::Response & res) { + try { + if (!auth_config_.enabled) { + res.status = StatusCode::NotFound_404; + res.set_content(json{{"error", "Authentication is not enabled"}}.dump(2), "application/json"); + return; + } + + // Parse request + AuthorizeRequest auth_req; + std::string content_type = req.get_header_value("Content-Type"); + + if (content_type.find("application/json") != std::string::npos) { + try { + json body = json::parse(req.body); + auth_req = AuthorizeRequest::from_json(body); + } catch (const json::parse_error & e) { + res.status = StatusCode::BadRequest_400; + res.set_content(AuthErrorResponse::invalid_request("Invalid JSON: " + std::string(e.what())).to_json().dump(2), + "application/json"); + return; + } + } else if (content_type.find("application/x-www-form-urlencoded") != std::string::npos) { + auth_req = AuthorizeRequest::from_form_data(req.body); + } else { + res.status = StatusCode::BadRequest_400; + res.set_content(AuthErrorResponse::invalid_request( + "Content-Type must be application/json or application/x-www-form-urlencoded") + .to_json() + .dump(2), + "application/json"); + return; + } + + // Validate grant_type + if (auth_req.grant_type != "refresh_token") { + res.status = StatusCode::BadRequest_400; + res.set_content( + AuthErrorResponse::unsupported_grant_type("Only 'refresh_token' grant type is supported on this endpoint") + .to_json() + .dump(2), + "application/json"); + return; + } + + // Validate required fields + if (!auth_req.refresh_token.has_value() || auth_req.refresh_token->empty()) { + res.status = StatusCode::BadRequest_400; + res.set_content(AuthErrorResponse::invalid_request("refresh_token is required").to_json().dump(2), + "application/json"); + return; + } + + // Refresh token + auto result = auth_manager_->refresh_access_token(auth_req.refresh_token.value()); + + if (result) { + res.set_content(result->to_json().dump(2), "application/json"); + } else { + res.status = StatusCode::Unauthorized_401; + res.set_content(result.error().to_json().dump(2), "application/json"); + } + } catch (const std::exception & e) { + res.status = StatusCode::InternalServerError_500; + res.set_content(json{{"error", "Internal server error"}, {"details", e.what()}}.dump(2), "application/json"); + RCLCPP_ERROR(rclcpp::get_logger("rest_server"), "Error in handle_auth_token: %s", e.what()); + } +} + +void RESTServer::handle_auth_revoke(const httplib::Request & req, httplib::Response & res) { + try { + if (!auth_config_.enabled) { + res.status = StatusCode::NotFound_404; + res.set_content(json{{"error", "Authentication is not enabled"}}.dump(2), "application/json"); + return; + } + + // Parse request + json body; + try { + body = json::parse(req.body); + } catch (const json::parse_error & e) { + res.status = StatusCode::BadRequest_400; + res.set_content(AuthErrorResponse::invalid_request("Invalid JSON: " + std::string(e.what())).to_json().dump(2), + "application/json"); + return; + } + + // Extract token to revoke + if (!body.contains("token") || !body["token"].is_string()) { + res.status = StatusCode::BadRequest_400; + res.set_content(AuthErrorResponse::invalid_request("token is required").to_json().dump(2), "application/json"); + return; + } + + std::string token = body["token"].get(); + + // Revoke token + bool revoked = auth_manager_->revoke_refresh_token(token); + + // Per OAuth2 spec, always return 200 even if token wasn't found + json response = {{"status", revoked ? "revoked" : "not_found"}}; + res.set_content(response.dump(2), "application/json"); + } catch (const std::exception & e) { + res.status = StatusCode::InternalServerError_500; + res.set_content(json{{"error", "Internal server error"}, {"details", e.what()}}.dump(2), "application/json"); + RCLCPP_ERROR(rclcpp::get_logger("rest_server"), "Error in handle_auth_revoke: %s", e.what()); + } +} + +bool RESTServer::check_auth(const httplib::Request & req, httplib::Response & res, const std::string & method, + const std::string & path) { + // If auth is not enabled, allow all requests + if (!auth_config_.enabled || !auth_manager_) { + return true; + } + + // Check if authentication is required for this request + if (!auth_manager_->requires_authentication(method, path)) { + return true; + } + + // Extract token from Authorization header + auto token = extract_bearer_token(req); + if (!token.has_value()) { + res.status = StatusCode::Unauthorized_401; + res.set_header("WWW-Authenticate", "Bearer realm=\"ros2_medkit_gateway\""); + res.set_content(AuthErrorResponse::invalid_token("Missing or invalid Authorization header").to_json().dump(2), + "application/json"); + return false; + } + + // Validate token + auto validation = auth_manager_->validate_token(token.value()); + if (!validation.valid) { + res.status = StatusCode::Unauthorized_401; + res.set_header("WWW-Authenticate", "Bearer realm=\"ros2_medkit_gateway\", error=\"invalid_token\""); + res.set_content(AuthErrorResponse::invalid_token(validation.error).to_json().dump(2), "application/json"); + return false; + } + + // Check authorization (RBAC) + auto auth_result = auth_manager_->check_authorization(validation.claims->role, method, path); + if (!auth_result.authorized) { + res.status = StatusCode::Forbidden_403; + res.set_content(AuthErrorResponse::insufficient_scope(auth_result.error).to_json().dump(2), "application/json"); + return false; + } + + return true; +} + +std::optional RESTServer::extract_bearer_token(const httplib::Request & req) const { + std::string auth_header = req.get_header_value("Authorization"); + if (auth_header.empty()) { + return std::nullopt; + } + + // Check for "Bearer " prefix (case-insensitive for "Bearer") + const std::string bearer_prefix = "Bearer "; + if (auth_header.length() < bearer_prefix.length()) { + return std::nullopt; + } + + std::string prefix = auth_header.substr(0, bearer_prefix.length()); + // Case-insensitive comparison for "Bearer " + if (prefix != "Bearer " && prefix != "bearer ") { + return std::nullopt; + } + + std::string token = auth_header.substr(bearer_prefix.length()); + if (token.empty()) { + return std::nullopt; + } + + return token; +} + } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/test_auth.test.py b/src/ros2_medkit_gateway/test/test_auth.test.py new file mode 100644 index 0000000..e2a45e1 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_auth.test.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +# Copyright 2025 bburda +# +# 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. + +"""Integration tests for JWT authentication and authorization. + +@verifies REQ_INTEROP_086, REQ_INTEROP_087 +""" + +import time +import unittest + +import launch +import launch_ros.actions +import launch_testing +import launch_testing.actions +import pytest +import requests + + +@pytest.mark.launch_test +def generate_test_description(): + """Generate launch description with gateway node with auth enabled.""" + gateway_node = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='gateway_node', + name='test_gateway_auth', + parameters=[{ + 'server.host': '127.0.0.1', + 'server.port': 8085, # Use different port to avoid conflicts + 'refresh_interval_ms': 1000, + 'auth.enabled': True, + 'auth.jwt_secret': 'test_secret_key_for_jwt_signing_integration_test_12345', + 'auth.jwt_algorithm': 'HS256', + 'auth.token_expiry_seconds': 3600, + 'auth.refresh_token_expiry_seconds': 86400, + 'auth.require_auth_for': 'write', + 'auth.issuer': 'test_gateway', + 'auth.clients': [ + 'admin:admin_secret:admin', + 'operator:operator_secret:operator', + 'viewer:viewer_secret:viewer', + 'configurator:configurator_secret:configurator', + ], + }], + output='screen', + ) + + return launch.LaunchDescription([ + gateway_node, + launch_testing.actions.ReadyToTest(), + ]), {'gateway_node': gateway_node} + + +class TestAuthenticationIntegration(unittest.TestCase): + """Integration tests for authentication endpoints.""" + + BASE_URL = 'http://127.0.0.1:8085/api/v1' + + @classmethod + def setUpClass(cls): + """Wait for gateway to be ready.""" + time.sleep(3) # Give the gateway time to start + + def test_01_health_endpoint_no_auth_required(self): + """Health endpoint should be accessible without authentication.""" + response = requests.get(f'{self.BASE_URL}/health', timeout=5) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['status'], 'healthy') + + def test_02_root_endpoint_shows_auth_enabled(self): + """Root endpoint should indicate auth is enabled.""" + response = requests.get(f'{self.BASE_URL}/', timeout=5) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['capabilities']['authentication']) + self.assertIn('auth', data) + self.assertTrue(data['auth']['enabled']) + self.assertEqual(data['auth']['algorithm'], 'HS256') + + def test_03_authenticate_valid_credentials(self): + """@verifies REQ_INTEROP_086 - Authentication with valid credentials.""" + response = requests.post( + f'{self.BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': 'admin', + 'client_secret': 'admin_secret', + }, + timeout=5, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn('access_token', data) + self.assertIn('refresh_token', data) + self.assertEqual(data['token_type'], 'Bearer') + self.assertEqual(data['scope'], 'admin') + self.assertEqual(data['expires_in'], 3600) + + def test_04_authenticate_invalid_credentials(self): + """Authentication with invalid credentials should fail.""" + response = requests.post( + f'{self.BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': 'admin', + 'client_secret': 'wrong_secret', + }, + timeout=5, + ) + self.assertEqual(response.status_code, 401) + data = response.json() + self.assertEqual(data['error'], 'invalid_client') + + def test_05_authenticate_unknown_client(self): + """Authentication with unknown client should fail.""" + response = requests.post( + f'{self.BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': 'unknown_client', + 'client_secret': 'some_secret', + }, + timeout=5, + ) + self.assertEqual(response.status_code, 401) + data = response.json() + self.assertEqual(data['error'], 'invalid_client') + + def test_06_authenticate_form_urlencoded(self): + """@verifies REQ_INTEROP_086 - Authentication with form-urlencoded.""" + response = requests.post( + f'{self.BASE_URL}/auth/authorize', + data='grant_type=client_credentials&client_id=viewer&client_secret=viewer_secret', + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + timeout=5, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['scope'], 'viewer') + + def test_07_refresh_token(self): + """@verifies REQ_INTEROP_087 - Token refresh flow.""" + # First, authenticate to get tokens + auth_response = requests.post( + f'{self.BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': 'operator', + 'client_secret': 'operator_secret', + }, + timeout=5, + ) + self.assertEqual(auth_response.status_code, 200) + tokens = auth_response.json() + refresh_token = tokens['refresh_token'] + + # Use refresh token to get new access token + refresh_response = requests.post( + f'{self.BASE_URL}/auth/token', + json={ + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token, + }, + timeout=5, + ) + self.assertEqual(refresh_response.status_code, 200) + new_tokens = refresh_response.json() + self.assertIn('access_token', new_tokens) + self.assertNotEqual(new_tokens['access_token'], tokens['access_token']) + self.assertEqual(new_tokens['scope'], 'operator') + + def test_08_refresh_invalid_token(self): + """Refresh with invalid token should fail.""" + response = requests.post( + f'{self.BASE_URL}/auth/token', + json={ + 'grant_type': 'refresh_token', + 'refresh_token': 'invalid_token', + }, + timeout=5, + ) + self.assertEqual(response.status_code, 401) + data = response.json() + self.assertEqual(data['error'], 'invalid_grant') + + def test_09_revoke_token(self): + """Token revocation flow.""" + # First, authenticate + auth_response = requests.post( + f'{self.BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': 'admin', + 'client_secret': 'admin_secret', + }, + timeout=5, + ) + tokens = auth_response.json() + refresh_token = tokens['refresh_token'] + + # Revoke the refresh token + revoke_response = requests.post( + f'{self.BASE_URL}/auth/revoke', + json={'token': refresh_token}, + timeout=5, + ) + self.assertEqual(revoke_response.status_code, 200) + data = revoke_response.json() + self.assertEqual(data['status'], 'revoked') + + # Try to use revoked refresh token + refresh_response = requests.post( + f'{self.BASE_URL}/auth/token', + json={ + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token, + }, + timeout=5, + ) + self.assertEqual(refresh_response.status_code, 401) + + def test_10_unsupported_grant_type(self): + """Unsupported grant type should fail.""" + response = requests.post( + f'{self.BASE_URL}/auth/authorize', + json={ + 'grant_type': 'authorization_code', + 'client_id': 'admin', + 'client_secret': 'admin_secret', + }, + timeout=5, + ) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data['error'], 'unsupported_grant_type') + + +class TestAuthorizationIntegration(unittest.TestCase): + """Integration tests for RBAC authorization.""" + + BASE_URL = 'http://127.0.0.1:8085/api/v1' + + @classmethod + def setUpClass(cls): + """Wait for gateway to be ready and get tokens.""" + time.sleep(1) + + # Get tokens for different roles + cls.tokens = {} + for role, client_id, client_secret in [ + ('admin', 'admin', 'admin_secret'), + ('operator', 'operator', 'operator_secret'), + ('viewer', 'viewer', 'viewer_secret'), + ('configurator', 'configurator', 'configurator_secret'), + ]: + response = requests.post( + f'{cls.BASE_URL}/auth/authorize', + json={ + 'grant_type': 'client_credentials', + 'client_id': client_id, + 'client_secret': client_secret, + }, + timeout=5, + ) + if response.status_code == 200: + cls.tokens[role] = response.json()['access_token'] + + def _auth_header(self, role): + """Get Authorization header for a role.""" + return {'Authorization': f'Bearer {self.tokens.get(role, "")}'} + + def test_01_read_endpoints_no_auth_required(self): + """GET endpoints should work without authentication (require_auth_for=write).""" + # These should work without auth + endpoints = [ + '/health', + '/version-info', + '/areas', + '/components', + ] + for endpoint in endpoints: + response = requests.get(f'{self.BASE_URL}{endpoint}', timeout=5) + self.assertEqual( + response.status_code, 200, + f'GET {endpoint} failed without auth: {response.status_code}' + ) + + def test_02_write_without_auth_returns_401(self): + """Write operations without auth should return 401.""" + # Note: These endpoints may not exist but should still require auth + response = requests.post( + f'{self.BASE_URL}/components/test/operations/calibrate', + json={}, + timeout=5, + ) + self.assertEqual(response.status_code, 401) + + response = requests.put( + f'{self.BASE_URL}/components/test/configurations/param', + json={'value': 123}, + timeout=5, + ) + self.assertEqual(response.status_code, 401) + + def test_03_write_with_invalid_token_returns_401(self): + """Write operations with invalid token should return 401.""" + response = requests.post( + f'{self.BASE_URL}/components/test/operations/calibrate', + json={}, + headers={'Authorization': 'Bearer invalid_token'}, + timeout=5, + ) + self.assertEqual(response.status_code, 401) + + def test_04_viewer_cannot_write(self): + """Viewer role should not be able to perform write operations.""" + headers = self._auth_header('viewer') + + # POST operation should be forbidden + response = requests.post( + f'{self.BASE_URL}/components/test/operations/calibrate', + json={}, + headers=headers, + timeout=5, + ) + self.assertEqual(response.status_code, 403) + + # PUT configuration should be forbidden + response = requests.put( + f'{self.BASE_URL}/components/test/configurations/param', + json={'value': 123}, + headers=headers, + timeout=5, + ) + self.assertEqual(response.status_code, 403) + + def test_05_operator_can_trigger_operations(self): + """Operator role should be able to trigger operations.""" + headers = self._auth_header('operator') + + # POST operation - may return 404 if component doesn't exist, + # but should not return 401 or 403 + response = requests.post( + f'{self.BASE_URL}/components/test_component/operations/test_op', + json={}, + headers=headers, + timeout=5, + ) + # 404 (not found) is acceptable - means auth passed but resource doesn't exist + self.assertIn(response.status_code, [200, 400, 404, 500]) + + def test_06_operator_cannot_modify_configurations(self): + """Operator role should not be able to modify configurations.""" + headers = self._auth_header('operator') + + response = requests.put( + f'{self.BASE_URL}/components/test/configurations/param', + json={'value': 123}, + headers=headers, + timeout=5, + ) + self.assertEqual(response.status_code, 403) + + def test_07_configurator_can_modify_configurations(self): + """Configurator role should be able to modify configurations.""" + headers = self._auth_header('configurator') + + # May return 404 if component doesn't exist, but not 401 or 403 + response = requests.put( + f'{self.BASE_URL}/components/test_component/configurations/param', + json={'value': 123}, + headers=headers, + timeout=5, + ) + self.assertIn(response.status_code, [200, 400, 404, 500, 503]) + + def test_08_admin_has_full_access(self): + """Admin role should have access to all operations.""" + headers = self._auth_header('admin') + + # All write operations should be authorized + endpoints = [ + ('POST', '/components/test/operations/calibrate', {}), + ('PUT', '/components/test/configurations/param', {'value': 123}), + ('DELETE', '/components/test/faults/F001', None), + ] + + for method, endpoint, body in endpoints: + if method == 'POST': + response = requests.post( + f'{self.BASE_URL}{endpoint}', + json=body, + headers=headers, + timeout=5, + ) + elif method == 'PUT': + response = requests.put( + f'{self.BASE_URL}{endpoint}', + json=body, + headers=headers, + timeout=5, + ) + elif method == 'DELETE': + response = requests.delete( + f'{self.BASE_URL}{endpoint}', + headers=headers, + timeout=5, + ) + + # Should not get 401 or 403 + self.assertNotIn( + response.status_code, [401, 403], + f'{method} {endpoint} returned auth error: {response.status_code}' + ) + + def test_09_www_authenticate_header_on_401(self): + """401 responses should include WWW-Authenticate header.""" + response = requests.post( + f'{self.BASE_URL}/components/test/operations/calibrate', + json={}, + timeout=5, + ) + self.assertEqual(response.status_code, 401) + self.assertIn('WWW-Authenticate', response.headers) + self.assertIn('Bearer', response.headers['WWW-Authenticate']) + + +@launch_testing.post_shutdown_test() +class TestAuthShutdown(unittest.TestCase): + """Post-shutdown tests.""" + + def test_exit_code(self, proc_info): + """Check that the gateway exited cleanly.""" + launch_testing.asserts.assertExitCodes(proc_info) diff --git a/src/ros2_medkit_gateway/test/test_auth_manager.cpp b/src/ros2_medkit_gateway/test/test_auth_manager.cpp new file mode 100644 index 0000000..96608b9 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_auth_manager.cpp @@ -0,0 +1,591 @@ +// Copyright 2025 bburda +// +// 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. + +#include + +#include +#include + +#include "ros2_medkit_gateway/auth_config.hpp" +#include "ros2_medkit_gateway/auth_manager.hpp" +#include "ros2_medkit_gateway/auth_models.hpp" + +using namespace ros2_medkit_gateway; + +// Test fixture for AuthManager tests +// @verifies REQ_INTEROP_086, REQ_INTEROP_087 +class AuthManagerTest : public ::testing::Test { + protected: + void SetUp() override { + // Create a test configuration with auth enabled + config_ = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("test_secret_key_for_jwt_signing_12345") + .with_algorithm(JwtAlgorithm::HS256) + .with_token_expiry(3600) + .with_refresh_token_expiry(86400) + .with_require_auth_for(AuthRequirement::WRITE) + .with_issuer("test_issuer") + .add_client("admin_user", "admin_password", UserRole::ADMIN) + .add_client("operator_user", "operator_password", UserRole::OPERATOR) + .add_client("viewer_user", "viewer_password", UserRole::VIEWER) + .add_client("configurator_user", "configurator_password", UserRole::CONFIGURATOR) + .build(); + + auth_manager_ = std::make_unique(config_); + } + + AuthConfig config_; + std::unique_ptr auth_manager_; +}; + +// Test configuration builder +TEST(AuthConfigBuilderTest, BuildValidConfig) { + AuthConfig config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("test_secret") + .with_algorithm(JwtAlgorithm::HS256) + .with_token_expiry(3600) + .with_refresh_token_expiry(86400) + .with_require_auth_for(AuthRequirement::WRITE) + .with_issuer("test_issuer") + .add_client("test_client", "test_secret", UserRole::ADMIN) + .build(); + + EXPECT_TRUE(config.enabled); + EXPECT_EQ(config.jwt_secret, "test_secret"); + EXPECT_EQ(config.jwt_algorithm, JwtAlgorithm::HS256); + EXPECT_EQ(config.token_expiry_seconds, 3600); + EXPECT_EQ(config.refresh_token_expiry_seconds, 86400); + EXPECT_EQ(config.require_auth_for, AuthRequirement::WRITE); + EXPECT_EQ(config.issuer, "test_issuer"); + EXPECT_EQ(config.clients.size(), 1); + EXPECT_EQ(config.clients[0].client_id, "test_client"); +} + +TEST(AuthConfigBuilderTest, BuildWithoutSecretThrows) { + EXPECT_THROW( + { AuthConfigBuilder().with_enabled(true).with_token_expiry(3600).with_refresh_token_expiry(86400).build(); }, + std::invalid_argument); +} + +TEST(AuthConfigBuilderTest, RefreshExpiryLessThanTokenExpiryThrows) { + EXPECT_THROW( + { + AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("secret") + .with_token_expiry(3600) + .with_refresh_token_expiry(1800) // Less than token expiry + .build(); + }, + std::invalid_argument); +} + +TEST(AuthConfigBuilderTest, DisabledConfigDoesNotRequireSecret) { + AuthConfig config = AuthConfigBuilder().with_enabled(false).build(); + + EXPECT_FALSE(config.enabled); +} + +// Test role/algorithm conversions +TEST(AuthConfigTest, RoleToString) { + EXPECT_EQ(role_to_string(UserRole::VIEWER), "viewer"); + EXPECT_EQ(role_to_string(UserRole::OPERATOR), "operator"); + EXPECT_EQ(role_to_string(UserRole::CONFIGURATOR), "configurator"); + EXPECT_EQ(role_to_string(UserRole::ADMIN), "admin"); +} + +TEST(AuthConfigTest, StringToRole) { + EXPECT_EQ(string_to_role("viewer"), UserRole::VIEWER); + EXPECT_EQ(string_to_role("operator"), UserRole::OPERATOR); + EXPECT_EQ(string_to_role("configurator"), UserRole::CONFIGURATOR); + EXPECT_EQ(string_to_role("admin"), UserRole::ADMIN); + // Case insensitive + EXPECT_EQ(string_to_role("ADMIN"), UserRole::ADMIN); + EXPECT_EQ(string_to_role("Viewer"), UserRole::VIEWER); +} + +TEST(AuthConfigTest, StringToRoleInvalid) { + EXPECT_THROW(string_to_role("invalid_role"), std::invalid_argument); +} + +TEST(AuthConfigTest, AlgorithmToString) { + EXPECT_EQ(algorithm_to_string(JwtAlgorithm::HS256), "HS256"); + EXPECT_EQ(algorithm_to_string(JwtAlgorithm::RS256), "RS256"); +} + +TEST(AuthConfigTest, StringToAlgorithm) { + EXPECT_EQ(string_to_algorithm("HS256"), JwtAlgorithm::HS256); + EXPECT_EQ(string_to_algorithm("RS256"), JwtAlgorithm::RS256); + EXPECT_EQ(string_to_algorithm("hs256"), JwtAlgorithm::HS256); +} + +TEST(AuthConfigTest, StringToAlgorithmInvalid) { + EXPECT_THROW(string_to_algorithm("invalid"), std::invalid_argument); +} + +TEST(AuthConfigTest, StringToAuthRequirement) { + EXPECT_EQ(string_to_auth_requirement("none"), AuthRequirement::NONE); + EXPECT_EQ(string_to_auth_requirement("write"), AuthRequirement::WRITE); + EXPECT_EQ(string_to_auth_requirement("all"), AuthRequirement::ALL); + EXPECT_EQ(string_to_auth_requirement("WRITE"), AuthRequirement::WRITE); +} + +TEST(AuthConfigTest, StringToAuthRequirementInvalid) { + EXPECT_THROW(string_to_auth_requirement("invalid"), std::invalid_argument); +} + +// Test AuthManager authentication +// @verifies REQ_INTEROP_086 +TEST_F(AuthManagerTest, AuthenticateValidCredentials) { + auto result = auth_manager_->authenticate("admin_user", "admin_password"); + + ASSERT_TRUE(result.has_value()); + EXPECT_FALSE(result->access_token.empty()); + EXPECT_FALSE(result->refresh_token.value_or("").empty()); + EXPECT_EQ(result->token_type, "Bearer"); + EXPECT_EQ(result->expires_in, 3600); + EXPECT_EQ(result->scope, "admin"); +} + +TEST_F(AuthManagerTest, AuthenticateInvalidClientId) { + auto result = auth_manager_->authenticate("nonexistent_client", "password"); + + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().error, "invalid_client"); +} + +TEST_F(AuthManagerTest, AuthenticateInvalidPassword) { + auto result = auth_manager_->authenticate("admin_user", "wrong_password"); + + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().error, "invalid_client"); +} + +// Test token validation +// @verifies REQ_INTEROP_087 +TEST_F(AuthManagerTest, ValidateValidToken) { + auto auth_result = auth_manager_->authenticate("admin_user", "admin_password"); + ASSERT_TRUE(auth_result.has_value()); + + auto validation = auth_manager_->validate_token(auth_result->access_token); + + EXPECT_TRUE(validation.valid); + EXPECT_TRUE(validation.claims.has_value()); + EXPECT_EQ(validation.claims->sub, "admin_user"); + EXPECT_EQ(validation.claims->role, UserRole::ADMIN); + EXPECT_EQ(validation.claims->iss, "test_issuer"); +} + +TEST_F(AuthManagerTest, ValidateInvalidToken) { + auto validation = auth_manager_->validate_token("invalid.token.here"); + + EXPECT_FALSE(validation.valid); + EXPECT_FALSE(validation.error.empty()); +} + +TEST_F(AuthManagerTest, ValidateTamperedToken) { + auto auth_result = auth_manager_->authenticate("admin_user", "admin_password"); + ASSERT_TRUE(auth_result.has_value()); + + // Tamper with the token + std::string tampered = auth_result->access_token; + if (!tampered.empty()) { + tampered[tampered.length() / 2] = 'X'; + } + + auto validation = auth_manager_->validate_token(tampered); + EXPECT_FALSE(validation.valid); +} + +// Test token refresh +// @verifies REQ_INTEROP_087 +TEST_F(AuthManagerTest, RefreshAccessToken) { + auto auth_result = auth_manager_->authenticate("operator_user", "operator_password"); + ASSERT_TRUE(auth_result.has_value()); + ASSERT_TRUE(auth_result->refresh_token.has_value()); + + auto refresh_result = auth_manager_->refresh_access_token(auth_result->refresh_token.value()); + + ASSERT_TRUE(refresh_result.has_value()); + EXPECT_FALSE(refresh_result->access_token.empty()); + EXPECT_EQ(refresh_result->scope, "operator"); + // Refresh should return a new access token + EXPECT_NE(refresh_result->access_token, auth_result->access_token); +} + +TEST_F(AuthManagerTest, RefreshWithInvalidToken) { + auto result = auth_manager_->refresh_access_token("invalid_refresh_token"); + + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().error, "invalid_grant"); +} + +// Test token revocation +TEST_F(AuthManagerTest, RevokeRefreshToken) { + auto auth_result = auth_manager_->authenticate("admin_user", "admin_password"); + ASSERT_TRUE(auth_result.has_value()); + ASSERT_TRUE(auth_result->refresh_token.has_value()); + + // Revoke the refresh token + bool revoked = auth_manager_->revoke_refresh_token(auth_result->refresh_token.value()); + EXPECT_TRUE(revoked); + + // Try to use the revoked refresh token + auto refresh_result = auth_manager_->refresh_access_token(auth_result->refresh_token.value()); + ASSERT_FALSE(refresh_result.has_value()); + EXPECT_EQ(refresh_result.error().error, "invalid_grant"); + + // Access token should still be valid (until expiry) + auto validation = auth_manager_->validate_token(auth_result->access_token); + EXPECT_FALSE(validation.valid); // Access token tied to revoked refresh token +} + +// Test RBAC authorization +// @verifies REQ_INTEROP_086 +TEST_F(AuthManagerTest, AuthorizeViewerCanRead) { + auto result = auth_manager_->check_authorization(UserRole::VIEWER, "GET", "/api/v1/components"); + EXPECT_TRUE(result.authorized); + + result = auth_manager_->check_authorization(UserRole::VIEWER, "GET", "/api/v1/components/engine/data"); + EXPECT_TRUE(result.authorized); + + result = auth_manager_->check_authorization(UserRole::VIEWER, "GET", "/api/v1/areas"); + EXPECT_TRUE(result.authorized); +} + +TEST_F(AuthManagerTest, AuthorizeViewerCannotWrite) { + auto result = + auth_manager_->check_authorization(UserRole::VIEWER, "POST", "/api/v1/components/engine/operations/calibrate"); + EXPECT_FALSE(result.authorized); + + result = + auth_manager_->check_authorization(UserRole::VIEWER, "PUT", "/api/v1/components/engine/configurations/threshold"); + EXPECT_FALSE(result.authorized); + + result = auth_manager_->check_authorization(UserRole::VIEWER, "DELETE", "/api/v1/components/engine/faults/F001"); + EXPECT_FALSE(result.authorized); +} + +TEST_F(AuthManagerTest, AuthorizeOperatorCanTriggerOperations) { + auto result = + auth_manager_->check_authorization(UserRole::OPERATOR, "POST", "/api/v1/components/engine/operations/calibrate"); + EXPECT_TRUE(result.authorized); + + result = auth_manager_->check_authorization(UserRole::OPERATOR, "DELETE", "/api/v1/components/engine/faults/F001"); + EXPECT_TRUE(result.authorized); + + result = auth_manager_->check_authorization(UserRole::OPERATOR, "PUT", "/api/v1/components/engine/data/temperature"); + EXPECT_TRUE(result.authorized); +} + +TEST_F(AuthManagerTest, AuthorizeOperatorCannotModifyConfigurations) { + auto result = auth_manager_->check_authorization(UserRole::OPERATOR, "PUT", + "/api/v1/components/engine/configurations/threshold"); + EXPECT_FALSE(result.authorized); +} + +TEST_F(AuthManagerTest, AuthorizeConfiguratorCanModifyConfigurations) { + auto result = auth_manager_->check_authorization(UserRole::CONFIGURATOR, "PUT", + "/api/v1/components/engine/configurations/threshold"); + EXPECT_TRUE(result.authorized); + + result = auth_manager_->check_authorization(UserRole::CONFIGURATOR, "DELETE", + "/api/v1/components/engine/configurations/threshold"); + EXPECT_TRUE(result.authorized); +} + +TEST_F(AuthManagerTest, AuthorizeAdminHasFullAccess) { + auto result = auth_manager_->check_authorization(UserRole::ADMIN, "GET", "/api/v1/components"); + EXPECT_TRUE(result.authorized); + + result = + auth_manager_->check_authorization(UserRole::ADMIN, "POST", "/api/v1/components/engine/operations/calibrate"); + EXPECT_TRUE(result.authorized); + + result = + auth_manager_->check_authorization(UserRole::ADMIN, "PUT", "/api/v1/components/engine/configurations/threshold"); + EXPECT_TRUE(result.authorized); + + result = auth_manager_->check_authorization(UserRole::ADMIN, "DELETE", "/api/v1/anything/goes"); + EXPECT_TRUE(result.authorized); +} + +// Test auth requirement checking +TEST_F(AuthManagerTest, RequiresAuthForWriteOnly) { + EXPECT_FALSE(auth_manager_->requires_authentication("GET", "/api/v1/components")); + EXPECT_TRUE(auth_manager_->requires_authentication("POST", "/api/v1/components/engine/operations/calibrate")); + EXPECT_TRUE(auth_manager_->requires_authentication("PUT", "/api/v1/components/engine/configurations/threshold")); + EXPECT_TRUE(auth_manager_->requires_authentication("DELETE", "/api/v1/components/engine/faults/F001")); +} + +TEST_F(AuthManagerTest, AuthEndpointsNeverRequireAuth) { + EXPECT_FALSE(auth_manager_->requires_authentication("POST", "/api/v1/auth/authorize")); + EXPECT_FALSE(auth_manager_->requires_authentication("POST", "/api/v1/auth/token")); + EXPECT_FALSE(auth_manager_->requires_authentication("POST", "/api/v1/auth/revoke")); +} + +// Test all auth requirement mode +TEST(AuthManagerRequirementTest, RequireAuthForAll) { + AuthConfig config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("test_secret") + .with_token_expiry(3600) + .with_refresh_token_expiry(86400) + .with_require_auth_for(AuthRequirement::ALL) + .build(); + + AuthManager manager(config); + + EXPECT_TRUE(manager.requires_authentication("GET", "/api/v1/components")); + EXPECT_TRUE(manager.requires_authentication("POST", "/api/v1/components/engine/operations/calibrate")); + // Auth endpoints still don't require auth + EXPECT_FALSE(manager.requires_authentication("POST", "/api/v1/auth/authorize")); +} + +// Test none auth requirement mode +TEST(AuthManagerRequirementTest, RequireAuthForNone) { + AuthConfig config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("test_secret") + .with_token_expiry(3600) + .with_refresh_token_expiry(86400) + .with_require_auth_for(AuthRequirement::NONE) + .build(); + + AuthManager manager(config); + + EXPECT_FALSE(manager.requires_authentication("GET", "/api/v1/components")); + EXPECT_FALSE(manager.requires_authentication("POST", "/api/v1/components/engine/operations/calibrate")); +} + +// Test disabled auth +TEST(AuthManagerDisabledTest, DisabledAuthManagerNeverRequiresAuth) { + AuthConfig config = AuthConfigBuilder().with_enabled(false).build(); + + AuthManager manager(config); + + EXPECT_FALSE(manager.is_enabled()); + EXPECT_FALSE(manager.requires_authentication("GET", "/api/v1/components")); + EXPECT_FALSE(manager.requires_authentication("POST", "/api/v1/components/engine/operations/calibrate")); +} + +// Test client registration +TEST_F(AuthManagerTest, RegisterNewClient) { + bool registered = auth_manager_->register_client("new_client", "new_secret", UserRole::VIEWER); + EXPECT_TRUE(registered); + + auto client = auth_manager_->get_client("new_client"); + ASSERT_TRUE(client.has_value()); + EXPECT_EQ(client->client_id, "new_client"); + EXPECT_EQ(client->role, UserRole::VIEWER); + + // Can authenticate with new client + auto result = auth_manager_->authenticate("new_client", "new_secret"); + EXPECT_TRUE(result.has_value()); +} + +TEST_F(AuthManagerTest, RegisterDuplicateClientFails) { + bool registered = auth_manager_->register_client("admin_user", "different_secret", UserRole::VIEWER); + EXPECT_FALSE(registered); +} + +// Test cleanup of expired tokens +TEST_F(AuthManagerTest, CleanupExpiredTokens) { + // Create config with very short expiry for testing + AuthConfig short_expiry_config = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("test_secret") + .with_token_expiry(1) // 1 second + .with_refresh_token_expiry(1) + .add_client("test", "test", UserRole::VIEWER) + .build(); + + AuthManager manager(short_expiry_config); + + // Authenticate to create tokens + auto result = manager.authenticate("test", "test"); + ASSERT_TRUE(result.has_value()); + + // Wait for tokens to expire + std::this_thread::sleep_for(std::chrono::seconds(2)); + + // Cleanup should remove expired tokens + size_t cleaned = manager.cleanup_expired_tokens(); + EXPECT_GE(cleaned, 1); +} + +// Test JwtClaims +TEST(JwtClaimsTest, ToJson) { + JwtClaims claims; + claims.iss = "test_issuer"; + claims.sub = "test_subject"; + claims.exp = 1234567890; + claims.iat = 1234567800; + claims.jti = "test_jti"; + claims.role = UserRole::ADMIN; + claims.permissions = {"read", "write"}; + claims.refresh_token_id = "refresh_123"; + + auto j = claims.to_json(); + + EXPECT_EQ(j["iss"], "test_issuer"); + EXPECT_EQ(j["sub"], "test_subject"); + EXPECT_EQ(j["exp"], 1234567890); + EXPECT_EQ(j["iat"], 1234567800); + EXPECT_EQ(j["jti"], "test_jti"); + EXPECT_EQ(j["role"], "admin"); + EXPECT_EQ(j["permissions"].size(), 2); + EXPECT_EQ(j["refresh_token_id"], "refresh_123"); +} + +TEST(JwtClaimsTest, FromJson) { + nlohmann::json j = { + {"iss", "test_issuer"}, {"sub", "test_subject"}, {"exp", 1234567890}, {"iat", 1234567800}, + {"jti", "test_jti"}, {"role", "operator"}, {"permissions", {"read"}}, {"refresh_token_id", "refresh_456"}}; + + auto claims = JwtClaims::from_json(j); + + EXPECT_EQ(claims.iss, "test_issuer"); + EXPECT_EQ(claims.sub, "test_subject"); + EXPECT_EQ(claims.exp, 1234567890); + EXPECT_EQ(claims.iat, 1234567800); + EXPECT_EQ(claims.jti, "test_jti"); + EXPECT_EQ(claims.role, UserRole::OPERATOR); + EXPECT_EQ(claims.permissions.size(), 1); + EXPECT_TRUE(claims.refresh_token_id.has_value()); + EXPECT_EQ(claims.refresh_token_id.value(), "refresh_456"); +} + +TEST(JwtClaimsTest, IsExpired) { + JwtClaims claims; + + // Expired token + claims.exp = 1000; + EXPECT_TRUE(claims.is_expired()); + + // Future token + auto future = std::chrono::system_clock::now() + std::chrono::hours(1); + claims.exp = std::chrono::duration_cast(future.time_since_epoch()).count(); + EXPECT_FALSE(claims.is_expired()); +} + +// Test TokenResponse +TEST(TokenResponseTest, ToJson) { + TokenResponse response; + response.access_token = "access_123"; + response.token_type = "Bearer"; + response.expires_in = 3600; + response.refresh_token = "refresh_456"; + response.scope = "admin"; + + auto j = response.to_json(); + + EXPECT_EQ(j["access_token"], "access_123"); + EXPECT_EQ(j["token_type"], "Bearer"); + EXPECT_EQ(j["expires_in"], 3600); + EXPECT_EQ(j["refresh_token"], "refresh_456"); + EXPECT_EQ(j["scope"], "admin"); +} + +TEST(TokenResponseTest, ToJsonWithoutRefreshToken) { + TokenResponse response; + response.access_token = "access_123"; + response.token_type = "Bearer"; + response.expires_in = 3600; + response.scope = "viewer"; + + auto j = response.to_json(); + + EXPECT_EQ(j["access_token"], "access_123"); + EXPECT_FALSE(j.contains("refresh_token")); +} + +// Test AuthErrorResponse +TEST(AuthErrorResponseTest, StandardErrors) { + auto invalid_request = AuthErrorResponse::invalid_request("Missing parameter"); + EXPECT_EQ(invalid_request.error, "invalid_request"); + EXPECT_EQ(invalid_request.error_description, "Missing parameter"); + + auto invalid_client = AuthErrorResponse::invalid_client("Unknown client"); + EXPECT_EQ(invalid_client.error, "invalid_client"); + + auto invalid_grant = AuthErrorResponse::invalid_grant("Token expired"); + EXPECT_EQ(invalid_grant.error, "invalid_grant"); + + auto unsupported_grant = AuthErrorResponse::unsupported_grant_type("Not supported"); + EXPECT_EQ(unsupported_grant.error, "unsupported_grant_type"); + + auto invalid_token = AuthErrorResponse::invalid_token("Malformed"); + EXPECT_EQ(invalid_token.error, "invalid_token"); + + auto insufficient_scope = AuthErrorResponse::insufficient_scope("Need admin"); + EXPECT_EQ(insufficient_scope.error, "insufficient_scope"); +} + +TEST(AuthErrorResponseTest, ToJson) { + auto error = AuthErrorResponse::invalid_request("Test description"); + auto j = error.to_json(); + + EXPECT_EQ(j["error"], "invalid_request"); + EXPECT_EQ(j["error_description"], "Test description"); +} + +// Test AuthorizeRequest form parsing +TEST(AuthorizeRequestTest, FromFormData) { + std::string form_data = "grant_type=client_credentials&client_id=test_client&client_secret=test_secret&scope=admin"; + + auto req = AuthorizeRequest::from_form_data(form_data); + + EXPECT_EQ(req.grant_type, "client_credentials"); + EXPECT_TRUE(req.client_id.has_value()); + EXPECT_EQ(req.client_id.value(), "test_client"); + EXPECT_TRUE(req.client_secret.has_value()); + EXPECT_EQ(req.client_secret.value(), "test_secret"); + EXPECT_TRUE(req.scope.has_value()); + EXPECT_EQ(req.scope.value(), "admin"); +} + +TEST(AuthorizeRequestTest, FromFormDataWithUrlEncoding) { + std::string form_data = "grant_type=refresh_token&refresh_token=abc%2Bdef%3D123"; + + auto req = AuthorizeRequest::from_form_data(form_data); + + EXPECT_EQ(req.grant_type, "refresh_token"); + EXPECT_TRUE(req.refresh_token.has_value()); + EXPECT_EQ(req.refresh_token.value(), "abc+def=123"); +} + +TEST(AuthorizeRequestTest, FromJson) { + nlohmann::json j = {{"grant_type", "client_credentials"}, + {"client_id", "test_client"}, + {"client_secret", "test_secret"}, + {"scope", "operator"}}; + + auto req = AuthorizeRequest::from_json(j); + + EXPECT_EQ(req.grant_type, "client_credentials"); + EXPECT_TRUE(req.client_id.has_value()); + EXPECT_EQ(req.client_id.value(), "test_client"); + EXPECT_TRUE(req.client_secret.has_value()); + EXPECT_EQ(req.client_secret.value(), "test_secret"); + EXPECT_TRUE(req.scope.has_value()); + EXPECT_EQ(req.scope.value(), "operator"); +} + +int main(int argc, char ** argv) { + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 9cb16b49a9ae132c60819c128876cb2f543af1b3 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 27 Dec 2025 20:49:12 +0000 Subject: [PATCH 2/2] feat(gateway): refactor JWT auth module with security improvements Reorganize authentication into auth/ subfolder following SRP principles and implement reviewer feedback items for improved security and code quality. - Move auth files to auth/ subfolder (auth_config, auth_models, auth_manager) - Extract AuthMiddleware class from REST server for separation of concerns - Introduce IAuthRequirementPolicy interface with Strategy pattern - Add factory pattern for creating auth requirement policies - DRY parse_request() helper for JSON/form-urlencoded content types - Add convenience header auth/auth.hpp for single-include usage - Token type enforcement via JWT 'typ' claim (access vs refresh) - Client state validation on every request (disabled clients rejected) - Thread-safe RNG using thread_local for token ID generation - RS256 fail-fast validation at startup (validate key files exist) - Refresh token revocation propagates to associated access tokens - Add SYSTEM flag to jwt-cpp FetchContent to suppress warnings - Disable jwt-cpp examples and tests (JWT_CPP_BUILD_EXAMPLES=OFF) - Configure clang-tidy HEADER_FILTER to exclude _deps folder --- src/ros2_medkit_gateway/CMakeLists.txt | 26 +- .../include/ros2_medkit_gateway/auth/auth.hpp | 29 + .../{ => auth}/auth_config.hpp | 0 .../{ => auth}/auth_manager.hpp | 25 +- .../auth/auth_middleware.hpp | 139 ++++ .../{ => auth}/auth_models.hpp | 68 +- .../auth/auth_requirement_policy.hpp | 178 ++++++ .../ros2_medkit_gateway/gateway_node.hpp | 2 +- .../ros2_medkit_gateway/rest_server.hpp | 25 +- .../src/{ => auth}/auth_config.cpp | 2 +- .../src/{ => auth}/auth_manager.cpp | 119 +++- .../src/auth/auth_middleware.cpp | 144 +++++ .../src/{ => auth}/auth_models.cpp | 21 +- .../src/auth/auth_requirement_policy.cpp | 175 +++++ src/ros2_medkit_gateway/src/rest_server.cpp | 140 +--- .../test/test_auth.test.py | 3 +- .../test/test_auth_manager.cpp | 601 +++++++++++++++++- 17 files changed, 1508 insertions(+), 189 deletions(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth.hpp rename src/ros2_medkit_gateway/include/ros2_medkit_gateway/{ => auth}/auth_config.hpp (100%) rename src/ros2_medkit_gateway/include/ros2_medkit_gateway/{ => auth}/auth_manager.hpp (87%) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_middleware.hpp rename src/ros2_medkit_gateway/include/ros2_medkit_gateway/{ => auth}/auth_models.hpp (75%) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_requirement_policy.hpp rename src/ros2_medkit_gateway/src/{ => auth}/auth_config.cpp (99%) rename src/ros2_medkit_gateway/src/{ => auth}/auth_manager.cpp (82%) create mode 100644 src/ros2_medkit_gateway/src/auth/auth_middleware.cpp rename src/ros2_medkit_gateway/src/{ => auth}/auth_models.cpp (68%) create mode 100644 src/ros2_medkit_gateway/src/auth/auth_requirement_policy.cpp diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index fb867bc..42de011 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -46,7 +46,11 @@ FetchContent_Declare( jwt-cpp GIT_REPOSITORY https://github.com/Thalhammer/jwt-cpp.git GIT_TAG v0.7.0 + SYSTEM # Treat as system headers to suppress warnings ) +# Disable jwt-cpp warnings by treating its headers as SYSTEM +set(JWT_CPP_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(JWT_CPP_BUILD_TESTS OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(jwt-cpp) # Include directories @@ -66,9 +70,12 @@ add_library(gateway_lib STATIC src/operation_manager.cpp src/configuration_manager.cpp src/fault_manager.cpp - src/auth_config.cpp - src/auth_models.cpp - src/auth_manager.cpp + # Auth module (subfolder) + src/auth/auth_config.cpp + src/auth/auth_models.cpp + src/auth/auth_manager.cpp + src/auth/auth_middleware.cpp + src/auth/auth_requirement_policy.cpp ) ament_target_dependencies(gateway_lib @@ -124,13 +131,22 @@ install(PROGRAMS scripts/get_type_schema.py if(BUILD_TESTING) # Linting and code quality checks find_package(ament_lint_auto REQUIRED) + find_package(ament_cmake_clang_tidy REQUIRED) # Use custom clang-format and clang-tidy configs from repo root set(ament_cmake_clang_format_CONFIG_FILE "${CMAKE_CURRENT_SOURCE_DIR}/../../.clang-format") set(ament_cmake_clang_tidy_CONFIG_FILE "${CMAKE_CURRENT_SOURCE_DIR}/../../.clang-tidy") - # Exclude uncrustify (conflicts with clang-format) and cpplint (conflicts with clang-format include order) - list(APPEND AMENT_LINT_AUTO_EXCLUDE ament_cmake_uncrustify ament_cmake_cpplint) + # Limit clang-tidy to only report issues from our source files (not FetchContent deps) + set(ament_cmake_clang_tidy_HEADER_FILTER "^${CMAKE_CURRENT_SOURCE_DIR}/(include|src|test)/") + + # Exclude linters that don't work well with FetchContent dependencies: + # - uncrustify/cpplint: conflicts with clang-format + # - copyright/lint_cmake: flags generated/fetched files in install/ + list(APPEND AMENT_LINT_AUTO_EXCLUDE + ament_cmake_uncrustify + ament_cmake_cpplint + ) ament_lint_auto_find_test_dependencies() # Add GTest diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth.hpp new file mode 100644 index 0000000..07d0e7f --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth.hpp @@ -0,0 +1,29 @@ +// Copyright 2025 bburda +// +// 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. + +/** + * @file auth.hpp + * @brief Convenience header that includes all authentication components + * + * This header provides a single include for all authentication-related + * functionality in the ros2_medkit_gateway. + */ + +#pragma once + +#include "ros2_medkit_gateway/auth/auth_config.hpp" +#include "ros2_medkit_gateway/auth/auth_manager.hpp" +#include "ros2_medkit_gateway/auth/auth_middleware.hpp" +#include "ros2_medkit_gateway/auth/auth_models.hpp" +#include "ros2_medkit_gateway/auth/auth_requirement_policy.hpp" diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_config.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_config.hpp similarity index 100% rename from src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_config.hpp rename to src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_config.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_manager.hpp similarity index 87% rename from src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_manager.hpp rename to src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_manager.hpp index c3356ab..57b61ea 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_manager.hpp @@ -20,8 +20,9 @@ #include #include -#include "ros2_medkit_gateway/auth_config.hpp" -#include "ros2_medkit_gateway/auth_models.hpp" +#include "ros2_medkit_gateway/auth/auth_config.hpp" +#include "ros2_medkit_gateway/auth/auth_models.hpp" +#include "ros2_medkit_gateway/auth/auth_requirement_policy.hpp" namespace ros2_medkit_gateway { @@ -86,9 +87,10 @@ class AuthManager { /** * @brief Validate a JWT access token * @param token The JWT token string + * @param expected_type Expected token type (defaults to ACCESS) * @return TokenValidationResult with claims if valid */ - TokenValidationResult validate_token(const std::string & token) const; + TokenValidationResult validate_token(const std::string & token, TokenType expected_type = TokenType::ACCESS) const; /** * @brief Check if a role is authorized for a specific HTTP method and path @@ -136,6 +138,20 @@ class AuthManager { */ std::optional get_client(const std::string & client_id) const; + /** + * @brief Disable a client (all tokens become invalid immediately) + * @param client_id Client identifier + * @return true if disabled, false if client not found + */ + bool disable_client(const std::string & client_id); + + /** + * @brief Enable a previously disabled client + * @param client_id Client identifier + * @return true if enabled, false if client not found + */ + bool enable_client(const std::string & client_id); + private: /** * @brief Generate a JWT token @@ -180,6 +196,9 @@ class AuthManager { AuthConfig config_; + // Auth requirement policy (created from config) + std::unique_ptr auth_policy_; + // Client credentials storage (thread-safe) mutable std::mutex clients_mutex_; std::unordered_map clients_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_middleware.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_middleware.hpp new file mode 100644 index 0000000..9385c3a --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_middleware.hpp @@ -0,0 +1,139 @@ +// Copyright 2025 bburda +// +// 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. + +#pragma once + +#include + +#include +#include +#include + +#include "ros2_medkit_gateway/auth/auth_config.hpp" +#include "ros2_medkit_gateway/auth/auth_manager.hpp" +#include "ros2_medkit_gateway/auth/auth_models.hpp" + +namespace ros2_medkit_gateway { + +/** + * @brief HTTP request abstraction for authentication + * + * This interface abstracts the HTTP request to decouple + * auth logic from the HTTP library (cpp-httplib). + */ +struct AuthRequest { + std::string method; + std::string path; + std::optional authorization_header; +}; + +/** + * @brief Result of authentication/authorization check + */ +struct AuthMiddlewareResult { + bool allowed{false}; + int status_code{0}; + std::string error_body; + std::string www_authenticate_header; +}; + +/** + * @brief Middleware class for handling HTTP authentication/authorization + * + * Separates the authentication middleware logic from the REST server, + * following the Single Responsibility Principle (SRP). + * + * This class: + * - Extracts bearer tokens from Authorization headers + * - Delegates token validation to AuthManager + * - Produces appropriate HTTP responses for auth failures + * + * @verifies REQ_INTEROP_086 + */ +class AuthMiddleware { + public: + /** + * @brief Construct AuthMiddleware with configuration and auth manager + * @param config Authentication configuration + * @param auth_manager Pointer to the auth manager (not owned) + */ + AuthMiddleware(const AuthConfig & config, AuthManager * auth_manager); + + /** + * @brief Check if authentication is enabled + * @return true if auth is enabled + */ + bool is_enabled() const { + return config_.enabled && auth_manager_ != nullptr; + } + + /** + * @brief Process an authentication request + * + * Checks if authentication is required, validates the token, + * and checks authorization for the requested resource. + * + * @param request The HTTP request abstraction + * @return AuthMiddlewareResult with success/failure and response details + */ + AuthMiddlewareResult process(const AuthRequest & request) const; + + /** + * @brief Extract bearer token from Authorization header + * @param auth_header The Authorization header value + * @return Token string if valid Bearer format, nullopt otherwise + */ + static std::optional extract_bearer_token(const std::string & auth_header); + + /** + * @brief Build AuthRequest from httplib::Request + * @param req The httplib request + * @return AuthRequest abstraction + */ + static AuthRequest from_httplib_request(const httplib::Request & req); + + /** + * @brief Apply AuthMiddlewareResult to httplib::Response + * @param result The auth result + * @param res The httplib response to modify + */ + static void apply_to_response(const AuthMiddlewareResult & result, httplib::Response & res); + + private: + /** + * @brief Build unauthorized response + * @param error_message Error message + * @param include_www_auth Whether to include WWW-Authenticate header + * @return AuthMiddlewareResult with 401 status + */ + static AuthMiddlewareResult make_unauthorized(const std::string & error_message, bool include_www_auth = true); + + /** + * @brief Build forbidden response + * @param error_message Error message + * @return AuthMiddlewareResult with 403 status + */ + static AuthMiddlewareResult make_forbidden(const std::string & error_message); + + /** + * @brief Build success response + * @return AuthMiddlewareResult with allowed=true + */ + static AuthMiddlewareResult make_success(); + + AuthConfig config_; + AuthManager * auth_manager_; ///< Non-owning pointer to auth manager +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_models.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_models.hpp similarity index 75% rename from src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_models.hpp rename to src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_models.hpp index 817821c..41bb47d 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth_models.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_models.hpp @@ -15,17 +15,57 @@ #pragma once #include +#include #include #include #include #include -#include "ros2_medkit_gateway/auth_config.hpp" +#include "ros2_medkit_gateway/auth/auth_config.hpp" namespace ros2_medkit_gateway { using json = nlohmann::json; +// Forward declarations +struct AuthErrorResponse; + +/** + * @brief JWT token type for distinguishing access vs refresh tokens + */ +enum class TokenType { + ACCESS, ///< Short-lived access token for API authorization + REFRESH ///< Long-lived refresh token for obtaining new access tokens +}; + +/** + * @brief Convert TokenType to string (for JWT typ claim) + */ +inline std::string token_type_to_string(TokenType type) { + switch (type) { + case TokenType::ACCESS: + return "access"; + case TokenType::REFRESH: + return "refresh"; + default: + return "unknown"; + } +} + +/** + * @brief Convert string to TokenType + * @throws std::invalid_argument if string is not a valid token type + */ +inline TokenType string_to_token_type(const std::string & type_str) { + if (type_str == "access") { + return TokenType::ACCESS; + } + if (type_str == "refresh") { + return TokenType::REFRESH; + } + throw std::invalid_argument("Invalid token type: " + type_str); +} + /** * @brief JWT token claims * @verifies REQ_INTEROP_087 @@ -36,12 +76,19 @@ struct JwtClaims { int64_t exp{0}; ///< Expiration time (Unix timestamp) int64_t iat{0}; ///< Issued at time (Unix timestamp) std::string jti; ///< JWT ID (unique identifier) + TokenType typ{TokenType::ACCESS}; ///< Token type (access or refresh) UserRole role{UserRole::VIEWER}; ///< User role for RBAC std::vector permissions; ///< Explicit permissions (optional) std::optional refresh_token_id; ///< Associated refresh token ID (for access tokens) json to_json() const { - json j = {{"iss", iss}, {"sub", sub}, {"exp", exp}, {"iat", iat}, {"jti", jti}, {"role", role_to_string(role)}}; + json j = {{"iss", iss}, + {"sub", sub}, + {"exp", exp}, + {"iat", iat}, + {"jti", jti}, + {"typ", token_type_to_string(typ)}, + {"role", role_to_string(role)}}; if (!permissions.empty()) { j["permissions"] = permissions; @@ -62,6 +109,14 @@ struct JwtClaims { claims.iat = j.value("iat", int64_t{0}); claims.jti = j.value("jti", ""); + if (j.contains("typ")) { + try { + claims.typ = string_to_token_type(j["typ"].get()); + } catch (const std::invalid_argument &) { + claims.typ = TokenType::ACCESS; // Default to access for backward compatibility + } + } + if (j.contains("role")) { claims.role = string_to_role(j["role"].get()); } @@ -142,6 +197,15 @@ struct AuthorizeRequest { // Parse from URL-encoded form data (application/x-www-form-urlencoded) static AuthorizeRequest from_form_data(const std::string & body); + + /** + * @brief Parse AuthorizeRequest from HTTP request body with content-type detection + * @param content_type Content-Type header value + * @param body Request body + * @return AuthorizeRequest on success, AuthErrorResponse on failure + */ + static std::expected parse_request(const std::string & content_type, + const std::string & body); }; /** diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_requirement_policy.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_requirement_policy.hpp new file mode 100644 index 0000000..59a953c --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/auth/auth_requirement_policy.hpp @@ -0,0 +1,178 @@ +// Copyright 2025 bburda +// +// 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. + +#pragma once + +#include +#include +#include +#include + +#include "ros2_medkit_gateway/auth/auth_config.hpp" + +namespace ros2_medkit_gateway { + +/** + * @brief Interface for authentication requirement policies + * + * Allows customizing when authentication is required for different + * HTTP methods and paths using the Strategy pattern. + * + * @verifies REQ_INTEROP_086 + */ +class IAuthRequirementPolicy { + public: + virtual ~IAuthRequirementPolicy() = default; + + /** + * @brief Check if authentication is required for a request + * @param method HTTP method (GET, POST, PUT, DELETE, etc.) + * @param path Request path + * @return true if authentication is required + */ + virtual bool requires_authentication(const std::string & method, const std::string & path) const = 0; + + /** + * @brief Get a human-readable description of this policy + * @return Description string + */ + virtual std::string description() const = 0; +}; + +/** + * @brief Policy that never requires authentication + */ +class NoAuthRequirementPolicy : public IAuthRequirementPolicy { + public: + bool requires_authentication(const std::string & method, const std::string & path) const override { + (void)method; + (void)path; + return false; + } + + std::string description() const override { + return "NoAuth: Authentication is never required"; + } +}; + +/** + * @brief Policy that always requires authentication + * + * Except for public endpoints (auth endpoints, health check) + */ +class AllAuthRequirementPolicy : public IAuthRequirementPolicy { + public: + bool requires_authentication(const std::string & method, const std::string & path) const override { + (void)method; + // Auth endpoints are always public (to allow login) + return path.find("/api/v1/auth/") != 0; + } + + std::string description() const override { + return "AllAuth: Authentication required for all endpoints except /auth/*"; + } +}; + +/** + * @brief Policy that requires authentication only for write operations + * + * Write operations: POST, PUT, DELETE, PATCH + * Read operations: GET, HEAD, OPTIONS (no auth required) + * + * Auth endpoints are always public. + */ +class WriteOnlyAuthRequirementPolicy : public IAuthRequirementPolicy { + public: + bool requires_authentication(const std::string & method, const std::string & path) const override { + // Auth endpoints are always public + if (path.find("/api/v1/auth/") == 0) { + return false; + } + + // Write operations require auth + return method == "POST" || method == "PUT" || method == "DELETE" || method == "PATCH"; + } + + std::string description() const override { + return "WriteOnly: Authentication required for POST, PUT, DELETE, PATCH operations"; + } +}; + +/** + * @brief Policy with configurable public paths + * + * Allows specifying a list of paths that don't require authentication. + * Supports wildcards (* for single segment, ** for multiple segments). + */ +class ConfigurableAuthRequirementPolicy : public IAuthRequirementPolicy { + public: + /** + * @brief Construct with list of public paths + * @param public_paths Paths that don't require authentication (supports wildcards) + * @param require_for_reads If true, require auth even for GET requests + */ + explicit ConfigurableAuthRequirementPolicy(const std::vector & public_paths, + bool require_for_reads = false); + + /** + * @brief Construct from auth requirements map + * @param auth_requirements Map of path patterns to auth requirement levels + */ + explicit ConfigurableAuthRequirementPolicy( + const std::unordered_map & auth_requirements); + + bool requires_authentication(const std::string & method, const std::string & path) const override; + + std::string description() const override { + return "Configurable: Custom per-path authentication requirements"; + } + + /** + * @brief Add a public path + * @param path Path pattern (supports * and ** wildcards) + */ + void add_public_path(const std::string & path); + + private: + bool is_public_path(const std::string & path) const; + AuthRequirement get_path_requirement(const std::string & path) const; + static bool matches_path(const std::string & pattern, const std::string & path); + + std::vector public_paths_; + std::unordered_map auth_requirements_; + bool require_for_reads_; + bool use_requirements_map_; +}; + +/** + * @brief Factory to create auth requirement policies from configuration + */ +class AuthRequirementPolicyFactory { + public: + /** + * @brief Create policy from AuthRequirement enum + * @param requirement The auth requirement level + * @return Policy implementation + */ + static std::unique_ptr create(AuthRequirement requirement); + + /** + * @brief Create policy from AuthConfig + * @param config Full auth configuration + * @return Policy implementation based on config.enabled and config.auth_requirements + */ + static std::unique_ptr create(const AuthConfig & config); +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp index a993ce1..275a43f 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp @@ -23,7 +23,7 @@ #include #include -#include "ros2_medkit_gateway/auth_config.hpp" +#include "ros2_medkit_gateway/auth/auth_config.hpp" #include "ros2_medkit_gateway/config.hpp" #include "ros2_medkit_gateway/configuration_manager.hpp" #include "ros2_medkit_gateway/data_access_manager.hpp" diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp index 01e8880..19fe8a3 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp @@ -22,8 +22,9 @@ #include #include -#include "ros2_medkit_gateway/auth_config.hpp" -#include "ros2_medkit_gateway/auth_manager.hpp" +#include "ros2_medkit_gateway/auth/auth_config.hpp" +#include "ros2_medkit_gateway/auth/auth_manager.hpp" +#include "ros2_medkit_gateway/auth/auth_middleware.hpp" #include "ros2_medkit_gateway/config.hpp" namespace ros2_medkit_gateway { @@ -79,31 +80,13 @@ class RESTServer { void set_cors_headers(httplib::Response & res, const std::string & origin) const; bool is_origin_allowed(const std::string & origin) const; - // Authentication middleware - /** - * @brief Check if request is authenticated and authorized - * @param req HTTP request - * @param res HTTP response (set error on failure) - * @param method HTTP method - * @param path Request path - * @return true if authorized, false if denied (response already set) - */ - bool check_auth(const httplib::Request & req, httplib::Response & res, const std::string & method, - const std::string & path); - - /** - * @brief Extract Bearer token from Authorization header - * @param req HTTP request - * @return Token string if present and valid format - */ - std::optional extract_bearer_token(const httplib::Request & req) const; - GatewayNode * node_; std::string host_; int port_; CorsConfig cors_config_; AuthConfig auth_config_; std::unique_ptr auth_manager_; + std::unique_ptr auth_middleware_; std::unique_ptr server_; }; diff --git a/src/ros2_medkit_gateway/src/auth_config.cpp b/src/ros2_medkit_gateway/src/auth/auth_config.cpp similarity index 99% rename from src/ros2_medkit_gateway/src/auth_config.cpp rename to src/ros2_medkit_gateway/src/auth/auth_config.cpp index 43e437c..0ebff14 100644 --- a/src/ros2_medkit_gateway/src/auth_config.cpp +++ b/src/ros2_medkit_gateway/src/auth/auth_config.cpp @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -#include "ros2_medkit_gateway/auth_config.hpp" +#include "ros2_medkit_gateway/auth/auth_config.hpp" #include #include diff --git a/src/ros2_medkit_gateway/src/auth_manager.cpp b/src/ros2_medkit_gateway/src/auth/auth_manager.cpp similarity index 82% rename from src/ros2_medkit_gateway/src/auth_manager.cpp rename to src/ros2_medkit_gateway/src/auth/auth_manager.cpp index 198652d..c9f7131 100644 --- a/src/ros2_medkit_gateway/src/auth_manager.cpp +++ b/src/ros2_medkit_gateway/src/auth/auth_manager.cpp @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -#include "ros2_medkit_gateway/auth_manager.hpp" +#include "ros2_medkit_gateway/auth/auth_manager.hpp" #include @@ -36,11 +36,36 @@ static std::string read_file_contents(const std::string & path) { return buffer.str(); } +// Helper to validate RSA key file exists and is readable +static void validate_key_file(const std::string & path, const std::string & key_type) { + if (path.empty()) { + throw std::runtime_error(key_type + " path is empty"); + } + std::ifstream file(path); + if (!file.is_open()) { + throw std::runtime_error("Failed to open " + key_type + " file: " + path); + } + // Check file is not empty + file.seekg(0, std::ios::end); + if (file.tellg() == 0) { + throw std::runtime_error(key_type + " file is empty: " + path); + } +} + AuthManager::AuthManager(const AuthConfig & config) : config_(config) { + // Validate RS256 key files at startup (fail fast) + if (config_.enabled && config_.jwt_algorithm == JwtAlgorithm::RS256) { + validate_key_file(config_.jwt_secret, "RS256 private key"); + validate_key_file(config_.jwt_public_key, "RS256 public key"); + } + // Initialize clients from config for (const auto & client : config_.clients) { clients_[client.client_id] = client; } + + // Create auth requirement policy from config + auth_policy_ = AuthRequirementPolicyFactory::create(config_.require_auth_for); } std::expected AuthManager::authenticate(const std::string & client_id, @@ -76,6 +101,7 @@ std::expected AuthManager::authenticate(const refresh_claims.iat = now_ts; refresh_claims.exp = now_ts + config_.refresh_token_expiry_seconds; refresh_claims.jti = refresh_token_id; + refresh_claims.typ = TokenType::REFRESH; // Mark as refresh token refresh_claims.role = client.role; std::string refresh_token = generate_jwt(refresh_claims); @@ -97,6 +123,7 @@ std::expected AuthManager::authenticate(const access_claims.iat = now_ts; access_claims.exp = now_ts + config_.token_expiry_seconds; access_claims.jti = generate_token_id(); + access_claims.typ = TokenType::ACCESS; // Mark as access token access_claims.role = client.role; access_claims.refresh_token_id = refresh_token_id; @@ -122,6 +149,11 @@ std::expected AuthManager::refresh_access_toke const auto & claims = decode_result.value(); + // Verify this is actually a refresh token, not an access token + if (claims.typ != TokenType::REFRESH) { + return std::unexpected(AuthErrorResponse::invalid_grant("Token is not a refresh token")); + } + // Check if refresh token exists and is not revoked auto record = get_refresh_token(claims.jti); if (!record.has_value()) { @@ -157,7 +189,8 @@ std::expected AuthManager::refresh_access_toke access_claims.iat = now_ts; access_claims.exp = now_ts + config_.token_expiry_seconds; access_claims.jti = generate_token_id(); - access_claims.role = record->role; // Use role from refresh token record + access_claims.typ = TokenType::ACCESS; // Mark as access token + access_claims.role = record->role; // Use role from refresh token record access_claims.refresh_token_id = claims.jti; std::string access_token = generate_jwt(access_claims); @@ -172,7 +205,7 @@ std::expected AuthManager::refresh_access_toke return response; } -TokenValidationResult AuthManager::validate_token(const std::string & token) const { +TokenValidationResult AuthManager::validate_token(const std::string & token, TokenType expected_type) const { TokenValidationResult result; auto decode_result = decode_jwt(token); @@ -184,6 +217,14 @@ TokenValidationResult AuthManager::validate_token(const std::string & token) con const auto & claims = decode_result.value(); + // Check token type matches expected + if (claims.typ != expected_type) { + result.valid = false; + result.error = "Invalid token type: expected " + token_type_to_string(expected_type) + ", got " + + token_type_to_string(claims.typ); + return result; + } + // Check expiration if (claims.is_expired()) { result.valid = false; @@ -191,6 +232,19 @@ TokenValidationResult AuthManager::validate_token(const std::string & token) con return result; } + // Check if client is still enabled (security: validate on every request) + auto client = get_client(claims.sub); + if (!client.has_value()) { + result.valid = false; + result.error = "Client no longer exists"; + return result; + } + if (!client->enabled) { + result.valid = false; + result.error = "Client has been disabled"; + return result; + } + // Check if associated refresh token is revoked (for access tokens) if (claims.refresh_token_id.has_value()) { auto record = get_refresh_token(claims.refresh_token_id.value()); @@ -261,25 +315,8 @@ bool AuthManager::requires_authentication(const std::string & method, const std: return false; } - // Auth endpoints are always accessible without auth (to allow login) - if (path.find("/api/v1/auth/") == 0) { - return false; - } - - switch (config_.require_auth_for) { - case AuthRequirement::NONE: - return false; - - case AuthRequirement::WRITE: - // Require auth for write operations - return method == "POST" || method == "PUT" || method == "DELETE" || method == "PATCH"; - - case AuthRequirement::ALL: - return true; - - default: - return false; - } + // Delegate to policy + return auth_policy_->requires_authentication(method, path); } bool AuthManager::revoke_refresh_token(const std::string & refresh_token) { @@ -346,8 +383,29 @@ std::optional AuthManager::get_client(const std::string & cli return it->second; } +bool AuthManager::disable_client(const std::string & client_id) { + std::lock_guard lock(clients_mutex_); + auto it = clients_.find(client_id); + if (it == clients_.end()) { + return false; + } + it->second.enabled = false; + return true; +} + +bool AuthManager::enable_client(const std::string & client_id) { + std::lock_guard lock(clients_mutex_); + auto it = clients_.find(client_id); + if (it == clients_.end()) { + return false; + } + it->second.enabled = true; + return true; +} + std::string AuthManager::generate_jwt(const JwtClaims & claims) const { auto builder = jwt::create() + .set_type(token_type_to_string(claims.typ)) // Set typ in JWT header .set_issuer(claims.iss) .set_subject(claims.sub) .set_issued_at(std::chrono::system_clock::from_time_t(claims.iat)) @@ -416,6 +474,15 @@ std::expected AuthManager::decode_jwt(const std::string claims.sub = decoded.get_subject(); claims.jti = decoded.get_id(); + // Extract typ from header + if (decoded.has_type()) { + try { + claims.typ = string_to_token_type(decoded.get_type()); + } catch (const std::invalid_argument &) { + claims.typ = TokenType::ACCESS; // Default for backward compatibility + } + } + auto exp_claim = decoded.get_expires_at(); claims.exp = std::chrono::duration_cast(exp_claim.time_since_epoch()).count(); @@ -444,10 +511,10 @@ std::expected AuthManager::decode_jwt(const std::string } std::string AuthManager::generate_token_id() { - // Generate UUID-like string - static std::random_device rd; - static std::mt19937_64 gen(rd()); - static std::uniform_int_distribution dis; + // Generate UUID-like string using thread-local RNG for thread safety + thread_local std::random_device rd; + thread_local std::mt19937_64 gen(rd()); + thread_local std::uniform_int_distribution dis; std::stringstream ss; ss << std::hex << std::setfill('0'); diff --git a/src/ros2_medkit_gateway/src/auth/auth_middleware.cpp b/src/ros2_medkit_gateway/src/auth/auth_middleware.cpp new file mode 100644 index 0000000..45073f2 --- /dev/null +++ b/src/ros2_medkit_gateway/src/auth/auth_middleware.cpp @@ -0,0 +1,144 @@ +// Copyright 2025 bburda +// +// 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. + +#include "ros2_medkit_gateway/auth/auth_middleware.hpp" + +#include + +namespace ros2_medkit_gateway { + +using json = nlohmann::json; + +AuthMiddleware::AuthMiddleware(const AuthConfig & config, AuthManager * auth_manager) + : config_(config), auth_manager_(auth_manager) { +} + +AuthMiddlewareResult AuthMiddleware::process(const AuthRequest & request) const { + // If auth is not enabled, allow all requests + if (!config_.enabled || auth_manager_ == nullptr) { + return make_success(); + } + + // Check if authentication is required for this request + if (!auth_manager_->requires_authentication(request.method, request.path)) { + return make_success(); + } + + // Extract token from Authorization header + if (!request.authorization_header.has_value()) { + return make_unauthorized("Missing Authorization header"); + } + + auto token = extract_bearer_token(request.authorization_header.value()); + if (!token.has_value()) { + return make_unauthorized("Invalid Authorization header format. Expected: Bearer "); + } + + // Validate token + auto validation = auth_manager_->validate_token(token.value()); + if (!validation.valid) { + return make_unauthorized(validation.error); + } + + // Check authorization (RBAC) + auto auth_result = auth_manager_->check_authorization(validation.claims->role, request.method, request.path); + if (!auth_result.authorized) { + return make_forbidden(auth_result.error); + } + + return make_success(); +} + +std::optional AuthMiddleware::extract_bearer_token(const std::string & auth_header) { + if (auth_header.empty()) { + return std::nullopt; + } + + // Check for "Bearer " prefix (case-insensitive for "Bearer") + const std::string bearer_prefix = "Bearer "; + if (auth_header.length() < bearer_prefix.length()) { + return std::nullopt; + } + + std::string prefix = auth_header.substr(0, bearer_prefix.length()); + // Case-insensitive comparison for "Bearer " + if (prefix != "Bearer " && prefix != "bearer ") { + return std::nullopt; + } + + std::string token = auth_header.substr(bearer_prefix.length()); + if (token.empty()) { + return std::nullopt; + } + + return token; +} + +AuthRequest AuthMiddleware::from_httplib_request(const httplib::Request & req) { + AuthRequest auth_req; + auth_req.method = req.method; + auth_req.path = req.path; + + std::string auth_header = req.get_header_value("Authorization"); + if (!auth_header.empty()) { + auth_req.authorization_header = auth_header; + } + + return auth_req; +} + +void AuthMiddleware::apply_to_response(const AuthMiddlewareResult & result, httplib::Response & res) { + if (result.allowed) { + return; // No modification needed for success + } + + res.status = result.status_code; + + if (!result.www_authenticate_header.empty()) { + res.set_header("WWW-Authenticate", result.www_authenticate_header); + } + + if (!result.error_body.empty()) { + res.set_content(result.error_body, "application/json"); + } +} + +AuthMiddlewareResult AuthMiddleware::make_unauthorized(const std::string & error_message, bool include_www_auth) { + AuthMiddlewareResult result; + result.allowed = false; + result.status_code = 401; + result.error_body = AuthErrorResponse::invalid_token(error_message).to_json().dump(2); + + if (include_www_auth) { + result.www_authenticate_header = "Bearer realm=\"ros2_medkit_gateway\", error=\"invalid_token\""; + } + + return result; +} + +AuthMiddlewareResult AuthMiddleware::make_forbidden(const std::string & error_message) { + AuthMiddlewareResult result; + result.allowed = false; + result.status_code = 403; + result.error_body = AuthErrorResponse::insufficient_scope(error_message).to_json().dump(2); + return result; +} + +AuthMiddlewareResult AuthMiddleware::make_success() { + AuthMiddlewareResult result; + result.allowed = true; + return result; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/auth_models.cpp b/src/ros2_medkit_gateway/src/auth/auth_models.cpp similarity index 68% rename from src/ros2_medkit_gateway/src/auth_models.cpp rename to src/ros2_medkit_gateway/src/auth/auth_models.cpp index 87d38a7..0e5a33b 100644 --- a/src/ros2_medkit_gateway/src/auth_models.cpp +++ b/src/ros2_medkit_gateway/src/auth/auth_models.cpp @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -#include "ros2_medkit_gateway/auth_models.hpp" +#include "ros2_medkit_gateway/auth/auth_models.hpp" #include @@ -68,4 +68,23 @@ AuthorizeRequest AuthorizeRequest::from_form_data(const std::string & body) { return req; } +std::expected AuthorizeRequest::parse_request(const std::string & content_type, + const std::string & body) { + if (content_type.find("application/json") != std::string::npos) { + try { + nlohmann::json json_body = nlohmann::json::parse(body); + return AuthorizeRequest::from_json(json_body); + } catch (const nlohmann::json::parse_error & e) { + return std::unexpected(AuthErrorResponse::invalid_request("Invalid JSON: " + std::string(e.what()))); + } + } + + if (content_type.find("application/x-www-form-urlencoded") != std::string::npos) { + return AuthorizeRequest::from_form_data(body); + } + + return std::unexpected( + AuthErrorResponse::invalid_request("Content-Type must be application/json or application/x-www-form-urlencoded")); +} + } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/auth/auth_requirement_policy.cpp b/src/ros2_medkit_gateway/src/auth/auth_requirement_policy.cpp new file mode 100644 index 0000000..ca7ad11 --- /dev/null +++ b/src/ros2_medkit_gateway/src/auth/auth_requirement_policy.cpp @@ -0,0 +1,175 @@ +// Copyright 2025 bburda +// +// 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. + +#include "ros2_medkit_gateway/auth/auth_requirement_policy.hpp" + +#include +#include + +namespace ros2_medkit_gateway { + +ConfigurableAuthRequirementPolicy::ConfigurableAuthRequirementPolicy(const std::vector & public_paths, + bool require_for_reads) + : public_paths_(public_paths), require_for_reads_(require_for_reads), use_requirements_map_(false) { + // Always add auth endpoints as public + public_paths_.push_back("/api/v1/auth/**"); +} + +ConfigurableAuthRequirementPolicy::ConfigurableAuthRequirementPolicy( + const std::unordered_map & auth_requirements) + : auth_requirements_(auth_requirements), require_for_reads_(false), use_requirements_map_(true) { + // Always add auth endpoints as public + auth_requirements_["/api/v1/auth/*"] = AuthRequirement::NONE; +} + +bool ConfigurableAuthRequirementPolicy::requires_authentication(const std::string & method, + const std::string & path) const { + if (use_requirements_map_) { + AuthRequirement requirement = get_path_requirement(path); + // NONE means no auth required + return requirement != AuthRequirement::NONE; + } + + // Original logic for public_paths mode + // Check if this is a public path + if (is_public_path(path)) { + return false; + } + + // If require_for_reads is false, GET/HEAD/OPTIONS don't need auth + if (!require_for_reads_) { + if (method == "GET" || method == "HEAD" || method == "OPTIONS") { + return false; + } + } + + return true; +} + +AuthRequirement ConfigurableAuthRequirementPolicy::get_path_requirement(const std::string & path) const { + // Try exact match first + auto it = auth_requirements_.find(path); + if (it != auth_requirements_.end()) { + return it->second; + } + + // Find the longest matching pattern + size_t longest_match_length = 0; + AuthRequirement best_match = AuthRequirement::ALL; // Default to requiring auth + + for (const auto & [pattern, requirement] : auth_requirements_) { + if (matches_path(pattern, path) && pattern.size() > longest_match_length) { + longest_match_length = pattern.size(); + best_match = requirement; + } + } + + return best_match; +} + +void ConfigurableAuthRequirementPolicy::add_public_path(const std::string & path) { + public_paths_.push_back(path); +} + +bool ConfigurableAuthRequirementPolicy::is_public_path(const std::string & path) const { + for (const auto & pattern : public_paths_) { + if (matches_path(pattern, path)) { + return true; + } + } + return false; +} + +bool ConfigurableAuthRequirementPolicy::matches_path(const std::string & pattern, const std::string & path) { + // Exact match + if (pattern == path) { + return true; + } + + // Convert pattern to regex + std::string regex_pattern; + regex_pattern.reserve(pattern.size() * 2); + + for (size_t i = 0; i < pattern.size(); ++i) { + char c = pattern[i]; + if (c == '*') { + // Check for ** (multi-segment wildcard) + if (i + 1 < pattern.size() && pattern[i + 1] == '*') { + regex_pattern += ".*"; // Match anything including slashes + ++i; // Skip the second * + } else { + regex_pattern += "[^/]+"; // Match any non-slash characters (single segment) + } + } else { + switch (c) { + case '.': + case '[': + case ']': + case '(': + case ')': + case '{': + case '}': + case '\\': + case '^': + case '$': + case '|': + case '?': + case '+': + regex_pattern += '\\'; + regex_pattern += c; + break; + default: + regex_pattern += c; + } + } + } + + // Anchor the pattern + regex_pattern = "^" + regex_pattern + "$"; + + try { + std::regex re(regex_pattern); + return std::regex_match(path, re); + } catch (const std::regex_error &) { + return false; + } +} + +std::unique_ptr AuthRequirementPolicyFactory::create(AuthRequirement requirement) { + switch (requirement) { + case AuthRequirement::NONE: + return std::make_unique(); + + case AuthRequirement::WRITE: + return std::make_unique(); + + case AuthRequirement::ALL: + return std::make_unique(); + + default: + return std::make_unique(); + } +} + +std::unique_ptr AuthRequirementPolicyFactory::create(const AuthConfig & config) { + // If auth is disabled, return NoAuth policy + if (!config.enabled) { + return std::make_unique(); + } + + // Use the require_auth_for setting from config + return create(config.require_auth_for); +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/rest_server.cpp b/src/ros2_medkit_gateway/src/rest_server.cpp index da50d14..43b4938 100644 --- a/src/ros2_medkit_gateway/src/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/rest_server.cpp @@ -20,7 +20,8 @@ #include #include -#include "ros2_medkit_gateway/auth_models.hpp" +#include "ros2_medkit_gateway/auth/auth_middleware.hpp" +#include "ros2_medkit_gateway/auth/auth_models.hpp" #include "ros2_medkit_gateway/exceptions.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" @@ -42,9 +43,10 @@ RESTServer::RESTServer(GatewayNode * node, const std::string & host, int port, c : node_(node), host_(host), port_(port), cors_config_(cors_config), auth_config_(auth_config) { server_ = std::make_unique(); - // Initialize auth manager if auth is enabled + // Initialize auth manager and middleware if auth is enabled if (auth_config_.enabled) { auth_manager_ = std::make_unique(auth_config_); + auth_middleware_ = std::make_unique(auth_config_, auth_manager_.get()); RCLCPP_INFO(rclcpp::get_logger("rest_server"), "Authentication enabled - algorithm: %s, require_auth_for: %s", algorithm_to_string(auth_config_.jwt_algorithm).c_str(), auth_config_.require_auth_for == AuthRequirement::NONE ? "none" @@ -84,13 +86,13 @@ RESTServer::RESTServer(GatewayNode * node, const std::string & host, int port, c } // Handle Authentication if enabled - if (auth_config_.enabled && auth_manager_) { - // Extract path from request - std::string path = req.path; + if (auth_middleware_ && auth_middleware_->is_enabled()) { + // Use AuthMiddleware to process the request + auto auth_request = AuthMiddleware::from_httplib_request(req); + auto result = auth_middleware_->process(auth_request); - // Check authentication - if (!check_auth(req, res, req.method, path)) { - // Response already set by check_auth + if (!result.allowed) { + AuthMiddleware::apply_to_response(result, res); return httplib::Server::HandlerResponse::Handled; } } @@ -2087,31 +2089,14 @@ void RESTServer::handle_auth_authorize(const httplib::Request & req, httplib::Re return; } - // Parse request - support both JSON and form-urlencoded - AuthorizeRequest auth_req; - std::string content_type = req.get_header_value("Content-Type"); - - if (content_type.find("application/json") != std::string::npos) { - try { - json body = json::parse(req.body); - auth_req = AuthorizeRequest::from_json(body); - } catch (const json::parse_error & e) { - res.status = StatusCode::BadRequest_400; - res.set_content(AuthErrorResponse::invalid_request("Invalid JSON: " + std::string(e.what())).to_json().dump(2), - "application/json"); - return; - } - } else if (content_type.find("application/x-www-form-urlencoded") != std::string::npos) { - auth_req = AuthorizeRequest::from_form_data(req.body); - } else { + // Parse request using DRY helper + auto parse_result = AuthorizeRequest::parse_request(req.get_header_value("Content-Type"), req.body); + if (!parse_result) { res.status = StatusCode::BadRequest_400; - res.set_content(AuthErrorResponse::invalid_request( - "Content-Type must be application/json or application/x-www-form-urlencoded") - .to_json() - .dump(2), - "application/json"); + res.set_content(parse_result.error().to_json().dump(2), "application/json"); return; } + auto auth_req = parse_result.value(); // Validate grant_type if (auth_req.grant_type != "client_credentials") { @@ -2162,31 +2147,14 @@ void RESTServer::handle_auth_token(const httplib::Request & req, httplib::Respon return; } - // Parse request - AuthorizeRequest auth_req; - std::string content_type = req.get_header_value("Content-Type"); - - if (content_type.find("application/json") != std::string::npos) { - try { - json body = json::parse(req.body); - auth_req = AuthorizeRequest::from_json(body); - } catch (const json::parse_error & e) { - res.status = StatusCode::BadRequest_400; - res.set_content(AuthErrorResponse::invalid_request("Invalid JSON: " + std::string(e.what())).to_json().dump(2), - "application/json"); - return; - } - } else if (content_type.find("application/x-www-form-urlencoded") != std::string::npos) { - auth_req = AuthorizeRequest::from_form_data(req.body); - } else { + // Parse request using DRY helper + auto parse_result = AuthorizeRequest::parse_request(req.get_header_value("Content-Type"), req.body); + if (!parse_result) { res.status = StatusCode::BadRequest_400; - res.set_content(AuthErrorResponse::invalid_request( - "Content-Type must be application/json or application/x-www-form-urlencoded") - .to_json() - .dump(2), - "application/json"); + res.set_content(parse_result.error().to_json().dump(2), "application/json"); return; } + auto auth_req = parse_result.value(); // Validate grant_type if (auth_req.grant_type != "refresh_token") { @@ -2264,72 +2232,4 @@ void RESTServer::handle_auth_revoke(const httplib::Request & req, httplib::Respo } } -bool RESTServer::check_auth(const httplib::Request & req, httplib::Response & res, const std::string & method, - const std::string & path) { - // If auth is not enabled, allow all requests - if (!auth_config_.enabled || !auth_manager_) { - return true; - } - - // Check if authentication is required for this request - if (!auth_manager_->requires_authentication(method, path)) { - return true; - } - - // Extract token from Authorization header - auto token = extract_bearer_token(req); - if (!token.has_value()) { - res.status = StatusCode::Unauthorized_401; - res.set_header("WWW-Authenticate", "Bearer realm=\"ros2_medkit_gateway\""); - res.set_content(AuthErrorResponse::invalid_token("Missing or invalid Authorization header").to_json().dump(2), - "application/json"); - return false; - } - - // Validate token - auto validation = auth_manager_->validate_token(token.value()); - if (!validation.valid) { - res.status = StatusCode::Unauthorized_401; - res.set_header("WWW-Authenticate", "Bearer realm=\"ros2_medkit_gateway\", error=\"invalid_token\""); - res.set_content(AuthErrorResponse::invalid_token(validation.error).to_json().dump(2), "application/json"); - return false; - } - - // Check authorization (RBAC) - auto auth_result = auth_manager_->check_authorization(validation.claims->role, method, path); - if (!auth_result.authorized) { - res.status = StatusCode::Forbidden_403; - res.set_content(AuthErrorResponse::insufficient_scope(auth_result.error).to_json().dump(2), "application/json"); - return false; - } - - return true; -} - -std::optional RESTServer::extract_bearer_token(const httplib::Request & req) const { - std::string auth_header = req.get_header_value("Authorization"); - if (auth_header.empty()) { - return std::nullopt; - } - - // Check for "Bearer " prefix (case-insensitive for "Bearer") - const std::string bearer_prefix = "Bearer "; - if (auth_header.length() < bearer_prefix.length()) { - return std::nullopt; - } - - std::string prefix = auth_header.substr(0, bearer_prefix.length()); - // Case-insensitive comparison for "Bearer " - if (prefix != "Bearer " && prefix != "bearer ") { - return std::nullopt; - } - - std::string token = auth_header.substr(bearer_prefix.length()); - if (token.empty()) { - return std::nullopt; - } - - return token; -} - } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/test_auth.test.py b/src/ros2_medkit_gateway/test/test_auth.test.py index e2a45e1..779add6 100644 --- a/src/ros2_medkit_gateway/test/test_auth.test.py +++ b/src/ros2_medkit_gateway/test/test_auth.test.py @@ -13,7 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Integration tests for JWT authentication and authorization. +""" +Integration tests for JWT authentication and authorization. @verifies REQ_INTEROP_086, REQ_INTEROP_087 """ diff --git a/src/ros2_medkit_gateway/test/test_auth_manager.cpp b/src/ros2_medkit_gateway/test/test_auth_manager.cpp index 96608b9..35894eb 100644 --- a/src/ros2_medkit_gateway/test/test_auth_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_auth_manager.cpp @@ -17,9 +17,7 @@ #include #include -#include "ros2_medkit_gateway/auth_config.hpp" -#include "ros2_medkit_gateway/auth_manager.hpp" -#include "ros2_medkit_gateway/auth_models.hpp" +#include "ros2_medkit_gateway/auth/auth.hpp" using namespace ros2_medkit_gateway; @@ -147,6 +145,23 @@ TEST(AuthConfigTest, StringToAuthRequirementInvalid) { EXPECT_THROW(string_to_auth_requirement("invalid"), std::invalid_argument); } +// Test TokenType +// @verifies REQ_INTEROP_087 +TEST(TokenTypeTest, TokenTypeToString) { + EXPECT_EQ(token_type_to_string(TokenType::ACCESS), "access"); + EXPECT_EQ(token_type_to_string(TokenType::REFRESH), "refresh"); +} + +TEST(TokenTypeTest, StringToTokenType) { + EXPECT_EQ(string_to_token_type("access"), TokenType::ACCESS); + EXPECT_EQ(string_to_token_type("refresh"), TokenType::REFRESH); +} + +TEST(TokenTypeTest, StringToTokenTypeInvalid) { + EXPECT_THROW(string_to_token_type("invalid"), std::invalid_argument); + EXPECT_THROW(string_to_token_type("ACCESS"), std::invalid_argument); // Case-sensitive +} + // Test AuthManager authentication // @verifies REQ_INTEROP_086 TEST_F(AuthManagerTest, AuthenticateValidCredentials) { @@ -210,6 +225,53 @@ TEST_F(AuthManagerTest, ValidateTamperedToken) { EXPECT_FALSE(validation.valid); } +// Test token type enforcement +// @verifies REQ_INTEROP_087 +TEST_F(AuthManagerTest, ValidateTokenWithCorrectType) { + auto auth_result = auth_manager_->authenticate("admin_user", "admin_password"); + ASSERT_TRUE(auth_result.has_value()); + + // Access token should validate as ACCESS type + auto access_validation = auth_manager_->validate_token(auth_result->access_token, TokenType::ACCESS); + EXPECT_TRUE(access_validation.valid); + EXPECT_EQ(access_validation.claims->typ, TokenType::ACCESS); + + // Refresh token should validate as REFRESH type + ASSERT_TRUE(auth_result->refresh_token.has_value()); + auto refresh_validation = auth_manager_->validate_token(auth_result->refresh_token.value(), TokenType::REFRESH); + EXPECT_TRUE(refresh_validation.valid); + EXPECT_EQ(refresh_validation.claims->typ, TokenType::REFRESH); +} + +// @verifies REQ_INTEROP_087 +TEST_F(AuthManagerTest, ValidateTokenWithWrongTypeRejectsToken) { + auto auth_result = auth_manager_->authenticate("admin_user", "admin_password"); + ASSERT_TRUE(auth_result.has_value()); + ASSERT_TRUE(auth_result->refresh_token.has_value()); + + // Access token should NOT validate as REFRESH type + auto access_as_refresh = auth_manager_->validate_token(auth_result->access_token, TokenType::REFRESH); + EXPECT_FALSE(access_as_refresh.valid); + EXPECT_TRUE(access_as_refresh.error.find("Invalid token type") != std::string::npos); + + // Refresh token should NOT validate as ACCESS type + auto refresh_as_access = auth_manager_->validate_token(auth_result->refresh_token.value(), TokenType::ACCESS); + EXPECT_FALSE(refresh_as_access.valid); + EXPECT_TRUE(refresh_as_access.error.find("Invalid token type") != std::string::npos); +} + +// @verifies REQ_INTEROP_087 +TEST_F(AuthManagerTest, RefreshWithAccessTokenFails) { + auto auth_result = auth_manager_->authenticate("admin_user", "admin_password"); + ASSERT_TRUE(auth_result.has_value()); + + // Try to use access token as refresh token (should fail due to type check) + auto refresh_result = auth_manager_->refresh_access_token(auth_result->access_token); + ASSERT_FALSE(refresh_result.has_value()); + EXPECT_EQ(refresh_result.error().error, "invalid_grant"); + EXPECT_TRUE(refresh_result.error().error_description.find("not a refresh token") != std::string::npos); +} + // Test token refresh // @verifies REQ_INTEROP_087 TEST_F(AuthManagerTest, RefreshAccessToken) { @@ -248,9 +310,29 @@ TEST_F(AuthManagerTest, RevokeRefreshToken) { ASSERT_FALSE(refresh_result.has_value()); EXPECT_EQ(refresh_result.error().error, "invalid_grant"); - // Access token should still be valid (until expiry) + // Access token should also be invalid (propagated revocation) auto validation = auth_manager_->validate_token(auth_result->access_token); - EXPECT_FALSE(validation.valid); // Access token tied to revoked refresh token + EXPECT_FALSE(validation.valid); + EXPECT_TRUE(validation.error.find("revoked") != std::string::npos); +} + +// @verifies REQ_INTEROP_087 +TEST_F(AuthManagerTest, RefreshRevocationPropagatestoAccessToken) { + // Get tokens + auto auth_result = auth_manager_->authenticate("operator_user", "operator_password"); + ASSERT_TRUE(auth_result.has_value()); + + // Access token is valid initially + auto validation = auth_manager_->validate_token(auth_result->access_token); + EXPECT_TRUE(validation.valid); + + // Revoke refresh token + auth_manager_->revoke_refresh_token(auth_result->refresh_token.value()); + + // Access token should now be invalid because its refresh token was revoked + validation = auth_manager_->validate_token(auth_result->access_token); + EXPECT_FALSE(validation.valid); + EXPECT_TRUE(validation.error.find("refresh token has been revoked") != std::string::npos); } // Test RBAC authorization @@ -382,6 +464,32 @@ TEST(AuthManagerDisabledTest, DisabledAuthManagerNeverRequiresAuth) { EXPECT_FALSE(manager.requires_authentication("POST", "/api/v1/components/engine/operations/calibrate")); } +// Test RS256 fail fast at startup +// @verifies REQ_INTEROP_087 +TEST(AuthManagerRS256Test, RS256WithMissingPrivateKeyThrows) { + AuthConfig config = AuthConfigBuilder() + .with_enabled(true) + .with_algorithm(JwtAlgorithm::RS256) + .with_jwt_secret("/nonexistent/private.pem") + .with_jwt_public_key("/nonexistent/public.pem") + .add_client("test", "test", UserRole::VIEWER) + .build(); + + EXPECT_THROW(AuthManager manager(config), std::runtime_error); +} + +TEST(AuthManagerRS256Test, RS256DisabledDoesNotValidateKeys) { + // When auth is disabled, RS256 keys should not be validated + AuthConfig config = AuthConfigBuilder() + .with_enabled(false) + .with_algorithm(JwtAlgorithm::RS256) + .with_jwt_secret("/nonexistent/private.pem") + .with_jwt_public_key("/nonexistent/public.pem") + .build(); + + EXPECT_NO_THROW(AuthManager manager(config)); +} + // Test client registration TEST_F(AuthManagerTest, RegisterNewClient) { bool registered = auth_manager_->register_client("new_client", "new_secret", UserRole::VIEWER); @@ -402,6 +510,56 @@ TEST_F(AuthManagerTest, RegisterDuplicateClientFails) { EXPECT_FALSE(registered); } +// Test client state validation on every request +// @verifies REQ_INTEROP_087 +TEST_F(AuthManagerTest, DisabledClientTokenBecomesInvalid) { + // Authenticate first + auto auth_result = auth_manager_->authenticate("admin_user", "admin_password"); + ASSERT_TRUE(auth_result.has_value()); + + // Token should be valid initially + auto validation = auth_manager_->validate_token(auth_result->access_token); + EXPECT_TRUE(validation.valid); + + // Disable the client + bool disabled = auth_manager_->disable_client("admin_user"); + EXPECT_TRUE(disabled); + + // Token should now be invalid + validation = auth_manager_->validate_token(auth_result->access_token); + EXPECT_FALSE(validation.valid); + EXPECT_TRUE(validation.error.find("disabled") != std::string::npos); +} + +TEST_F(AuthManagerTest, ReenabledClientTokenBecomesValid) { + // Authenticate first + auto auth_result = auth_manager_->authenticate("admin_user", "admin_password"); + ASSERT_TRUE(auth_result.has_value()); + + // Disable the client + auth_manager_->disable_client("admin_user"); + auto validation = auth_manager_->validate_token(auth_result->access_token); + EXPECT_FALSE(validation.valid); + + // Re-enable the client + bool enabled = auth_manager_->enable_client("admin_user"); + EXPECT_TRUE(enabled); + + // Token should be valid again + validation = auth_manager_->validate_token(auth_result->access_token); + EXPECT_TRUE(validation.valid); +} + +TEST_F(AuthManagerTest, DisableNonexistentClientFails) { + bool disabled = auth_manager_->disable_client("nonexistent_client"); + EXPECT_FALSE(disabled); +} + +TEST_F(AuthManagerTest, EnableNonexistentClientFails) { + bool enabled = auth_manager_->enable_client("nonexistent_client"); + EXPECT_FALSE(enabled); +} + // Test cleanup of expired tokens TEST_F(AuthManagerTest, CleanupExpiredTokens) { // Create config with very short expiry for testing @@ -435,6 +593,7 @@ TEST(JwtClaimsTest, ToJson) { claims.exp = 1234567890; claims.iat = 1234567800; claims.jti = "test_jti"; + claims.typ = TokenType::ACCESS; claims.role = UserRole::ADMIN; claims.permissions = {"read", "write"}; claims.refresh_token_id = "refresh_123"; @@ -446,15 +605,32 @@ TEST(JwtClaimsTest, ToJson) { EXPECT_EQ(j["exp"], 1234567890); EXPECT_EQ(j["iat"], 1234567800); EXPECT_EQ(j["jti"], "test_jti"); + EXPECT_EQ(j["typ"], "access"); EXPECT_EQ(j["role"], "admin"); EXPECT_EQ(j["permissions"].size(), 2); EXPECT_EQ(j["refresh_token_id"], "refresh_123"); } +TEST(JwtClaimsTest, ToJsonRefreshToken) { + JwtClaims claims; + claims.iss = "test_issuer"; + claims.sub = "test_subject"; + claims.exp = 1234567890; + claims.iat = 1234567800; + claims.jti = "test_jti"; + claims.typ = TokenType::REFRESH; + claims.role = UserRole::OPERATOR; + + auto j = claims.to_json(); + + EXPECT_EQ(j["typ"], "refresh"); + EXPECT_EQ(j["role"], "operator"); +} + TEST(JwtClaimsTest, FromJson) { - nlohmann::json j = { - {"iss", "test_issuer"}, {"sub", "test_subject"}, {"exp", 1234567890}, {"iat", 1234567800}, - {"jti", "test_jti"}, {"role", "operator"}, {"permissions", {"read"}}, {"refresh_token_id", "refresh_456"}}; + nlohmann::json j = {{"iss", "test_issuer"}, {"sub", "test_subject"}, {"exp", 1234567890}, + {"iat", 1234567800}, {"jti", "test_jti"}, {"typ", "access"}, + {"role", "operator"}, {"permissions", {"read"}}, {"refresh_token_id", "refresh_456"}}; auto claims = JwtClaims::from_json(j); @@ -463,12 +639,33 @@ TEST(JwtClaimsTest, FromJson) { EXPECT_EQ(claims.exp, 1234567890); EXPECT_EQ(claims.iat, 1234567800); EXPECT_EQ(claims.jti, "test_jti"); + EXPECT_EQ(claims.typ, TokenType::ACCESS); EXPECT_EQ(claims.role, UserRole::OPERATOR); EXPECT_EQ(claims.permissions.size(), 1); EXPECT_TRUE(claims.refresh_token_id.has_value()); EXPECT_EQ(claims.refresh_token_id.value(), "refresh_456"); } +TEST(JwtClaimsTest, FromJsonRefreshToken) { + nlohmann::json j = {{"iss", "test_issuer"}, {"sub", "test_subject"}, {"exp", 1234567890}, {"iat", 1234567800}, + {"jti", "test_jti"}, {"typ", "refresh"}, {"role", "admin"}}; + + auto claims = JwtClaims::from_json(j); + + EXPECT_EQ(claims.typ, TokenType::REFRESH); + EXPECT_EQ(claims.role, UserRole::ADMIN); +} + +TEST(JwtClaimsTest, FromJsonWithInvalidTypDefaultsToAccess) { + nlohmann::json j = {{"iss", "test_issuer"}, {"sub", "test_subject"}, {"exp", 1234567890}, {"iat", 1234567800}, + {"jti", "test_jti"}, {"typ", "unknown_type"}, {"role", "admin"}}; + + auto claims = JwtClaims::from_json(j); + + // Should default to ACCESS for backward compatibility + EXPECT_EQ(claims.typ, TokenType::ACCESS); +} + TEST(JwtClaimsTest, IsExpired) { JwtClaims claims; @@ -585,6 +782,394 @@ TEST(AuthorizeRequestTest, FromJson) { EXPECT_EQ(req.scope.value(), "operator"); } +// @verifies REQ_INTEROP_086 +TEST(AuthorizeRequestTest, ParseRequestJson) { + std::string content_type = "application/json"; + std::string body = R"({"grant_type": "client_credentials", "client_id": "test", "client_secret": "secret"})"; + + auto result = AuthorizeRequest::parse_request(content_type, body); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->grant_type, "client_credentials"); + EXPECT_TRUE(result->client_id.has_value()); + EXPECT_EQ(result->client_id.value(), "test"); +} + +// @verifies REQ_INTEROP_086 +TEST(AuthorizeRequestTest, ParseRequestFormUrlEncoded) { + std::string content_type = "application/x-www-form-urlencoded"; + std::string body = "grant_type=client_credentials&client_id=test&client_secret=secret"; + + auto result = AuthorizeRequest::parse_request(content_type, body); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->grant_type, "client_credentials"); + EXPECT_TRUE(result->client_id.has_value()); + EXPECT_EQ(result->client_id.value(), "test"); +} + +// @verifies REQ_INTEROP_086 +TEST(AuthorizeRequestTest, ParseRequestInvalidContentType) { + std::string content_type = "text/plain"; + std::string body = "some text"; + + auto result = AuthorizeRequest::parse_request(content_type, body); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().error, "invalid_request"); +} + +// @verifies REQ_INTEROP_086 +TEST(AuthorizeRequestTest, ParseRequestInvalidJson) { + std::string content_type = "application/json"; + std::string body = "{ invalid json }"; + + auto result = AuthorizeRequest::parse_request(content_type, body); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().error, "invalid_request"); +} + +// @verifies REQ_INTEROP_086 +TEST(AuthorizeRequestTest, ParseRequestJsonWithCharset) { + // Content-Type may include charset + std::string content_type = "application/json; charset=utf-8"; + std::string body = R"({"grant_type": "client_credentials"})"; + + auto result = AuthorizeRequest::parse_request(content_type, body); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->grant_type, "client_credentials"); +} + +// ============================================================================ +// AuthMiddleware tests +// ============================================================================ + +class AuthMiddlewareTest : public ::testing::Test { + protected: + void SetUp() override { + config_ = AuthConfigBuilder() + .with_enabled(true) + .with_jwt_secret("test_secret_key_for_middleware_test_12345") + .with_algorithm(JwtAlgorithm::HS256) + .with_token_expiry(3600) + .with_refresh_token_expiry(86400) + .with_require_auth_for(AuthRequirement::WRITE) + .add_client("test_admin", "admin_secret", UserRole::ADMIN) + .add_client("test_viewer", "viewer_secret", UserRole::VIEWER) + .build(); + + auth_manager_ = std::make_unique(config_); + middleware_ = std::make_unique(config_, auth_manager_.get()); + } + + AuthConfig config_; + std::unique_ptr auth_manager_; + std::unique_ptr middleware_; +}; + +// @verifies REQ_INTEROP_086 +TEST_F(AuthMiddlewareTest, ExtractBearerToken_ValidToken) { + auto token = AuthMiddleware::extract_bearer_token("Bearer abc123xyz"); + ASSERT_TRUE(token.has_value()); + EXPECT_EQ(token.value(), "abc123xyz"); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthMiddlewareTest, ExtractBearerToken_CaseInsensitive) { + auto token = AuthMiddleware::extract_bearer_token("bearer abc123xyz"); + ASSERT_TRUE(token.has_value()); + EXPECT_EQ(token.value(), "abc123xyz"); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthMiddlewareTest, ExtractBearerToken_EmptyHeader) { + auto token = AuthMiddleware::extract_bearer_token(""); + EXPECT_FALSE(token.has_value()); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthMiddlewareTest, ExtractBearerToken_InvalidPrefix) { + auto token = AuthMiddleware::extract_bearer_token("Basic abc123xyz"); + EXPECT_FALSE(token.has_value()); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthMiddlewareTest, ExtractBearerToken_EmptyToken) { + auto token = AuthMiddleware::extract_bearer_token("Bearer "); + EXPECT_FALSE(token.has_value()); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthMiddlewareTest, ProcessGetRequestWithoutAuth) { + // GET requests don't require auth when require_auth_for=WRITE + AuthRequest req; + req.method = "GET"; + req.path = "/api/v1/components"; + + auto result = middleware_->process(req); + EXPECT_TRUE(result.allowed); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthMiddlewareTest, ProcessWriteRequestWithoutAuth) { + // POST requests require auth when require_auth_for=WRITE + AuthRequest req; + req.method = "POST"; + req.path = "/api/v1/components/engine/operations/calibrate"; + + auto result = middleware_->process(req); + EXPECT_FALSE(result.allowed); + EXPECT_EQ(result.status_code, 401); +} + +// @verifies REQ_INTEROP_086, REQ_INTEROP_087 +TEST_F(AuthMiddlewareTest, ProcessWriteRequestWithValidToken) { + // Authenticate first to get a valid token + auto auth_result = auth_manager_->authenticate("test_admin", "admin_secret"); + ASSERT_TRUE(auth_result.has_value()); + + // POST request with valid admin token + AuthRequest req; + req.method = "POST"; + req.path = "/api/v1/components/engine/operations/calibrate"; + req.authorization_header = "Bearer " + auth_result->access_token; + + auto result = middleware_->process(req); + EXPECT_TRUE(result.allowed); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthMiddlewareTest, ProcessWriteRequestWithInvalidToken) { + AuthRequest req; + req.method = "POST"; + req.path = "/api/v1/components/engine/operations/calibrate"; + req.authorization_header = "Bearer invalid_token_12345"; + + auto result = middleware_->process(req); + EXPECT_FALSE(result.allowed); + EXPECT_EQ(result.status_code, 401); + EXPECT_FALSE(result.www_authenticate_header.empty()); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthMiddlewareTest, ProcessWriteRequestWithInsufficientPermissions) { + // Authenticate as viewer + auto auth_result = auth_manager_->authenticate("test_viewer", "viewer_secret"); + ASSERT_TRUE(auth_result.has_value()); + + // Viewer trying to POST (not allowed) + AuthRequest req; + req.method = "POST"; + req.path = "/api/v1/components/engine/operations/calibrate"; + req.authorization_header = "Bearer " + auth_result->access_token; + + auto result = middleware_->process(req); + EXPECT_FALSE(result.allowed); + EXPECT_EQ(result.status_code, 403); // Forbidden, not Unauthorized +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthMiddlewareTest, AuthEndpointsNeverRequireAuth) { + AuthRequest req; + req.method = "POST"; + req.path = "/api/v1/auth/authorize"; + // No authorization header + + auto result = middleware_->process(req); + EXPECT_TRUE(result.allowed); // Auth endpoints are always accessible +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthMiddlewareTest, DisabledMiddlewareAllowsAll) { + AuthConfig disabled_config; + disabled_config.enabled = false; + + AuthMiddleware disabled_middleware(disabled_config, nullptr); + + AuthRequest req; + req.method = "POST"; + req.path = "/api/v1/components/engine/operations/calibrate"; + + auto result = disabled_middleware.process(req); + EXPECT_TRUE(result.allowed); +} + +// ============================================================================ +// AuthRequirementPolicy Tests +// ============================================================================ + +class AuthRequirementPolicyTest : public ::testing::Test { + protected: + void SetUp() override { + // Set up auth requirements for configurable policy testing + auth_requirements_ = { + {"/api/v1/version", AuthRequirement::NONE}, + {"/api/v1/health", AuthRequirement::NONE}, + {"/api/v1/components", AuthRequirement::ALL}, + {"/api/v1/components/*", AuthRequirement::ALL}, + {"/api/v1/components/*/data/*", AuthRequirement::WRITE}, + {"/api/v1/admin/*", AuthRequirement::ALL}, + }; + } + + std::unordered_map auth_requirements_; +}; + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, NoAuthPolicyNeverRequiresAuth) { + NoAuthRequirementPolicy policy; + + EXPECT_FALSE(policy.requires_authentication("GET", "/api/v1/version")); + EXPECT_FALSE(policy.requires_authentication("POST", "/api/v1/components/engine/operations")); + EXPECT_FALSE(policy.requires_authentication("DELETE", "/api/v1/admin/users")); + EXPECT_FALSE(policy.requires_authentication("PUT", "/anything")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, AllAuthPolicyAlwaysRequiresAuth) { + AllAuthRequirementPolicy policy; + + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/version")); + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/components")); + EXPECT_TRUE(policy.requires_authentication("POST", "/api/v1/components/engine/operations")); + EXPECT_TRUE(policy.requires_authentication("DELETE", "/api/v1/admin/users")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, WriteOnlyPolicyForGetRequests) { + WriteOnlyAuthRequirementPolicy policy; + + // GET requests don't require auth + EXPECT_FALSE(policy.requires_authentication("GET", "/api/v1/version")); + EXPECT_FALSE(policy.requires_authentication("GET", "/api/v1/components")); + EXPECT_FALSE(policy.requires_authentication("GET", "/api/v1/admin/users")); + EXPECT_FALSE(policy.requires_authentication("HEAD", "/api/v1/health")); + EXPECT_FALSE(policy.requires_authentication("OPTIONS", "/api/v1/anything")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, WriteOnlyPolicyForWriteRequests) { + WriteOnlyAuthRequirementPolicy policy; + + // Write requests require auth + EXPECT_TRUE(policy.requires_authentication("POST", "/api/v1/components/engine/operations")); + EXPECT_TRUE(policy.requires_authentication("PUT", "/api/v1/components/engine/config")); + EXPECT_TRUE(policy.requires_authentication("DELETE", "/api/v1/admin/users")); + EXPECT_TRUE(policy.requires_authentication("PATCH", "/api/v1/components/engine")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, ConfigurablePolicyExactMatch) { + ConfigurableAuthRequirementPolicy policy(auth_requirements_); + + // Public endpoints (NONE) + EXPECT_FALSE(policy.requires_authentication("GET", "/api/v1/version")); + EXPECT_FALSE(policy.requires_authentication("GET", "/api/v1/health")); + EXPECT_FALSE(policy.requires_authentication("POST", "/api/v1/version")); // NONE for any method +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, ConfigurablePolicyWildcardMatch) { + ConfigurableAuthRequirementPolicy policy(auth_requirements_); + + // Wildcard match for /api/v1/components/* + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/components/engine")); + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/components/sensor")); + + // Admin wildcard + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/admin/users")); + EXPECT_TRUE(policy.requires_authentication("POST", "/api/v1/admin/settings")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, ConfigurablePolicyMultipleWildcards) { + ConfigurableAuthRequirementPolicy policy(auth_requirements_); + + // /api/v1/components/*/data/* should match + EXPECT_TRUE(policy.requires_authentication("POST", "/api/v1/components/engine/data/temperature")); + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/components/sensor/data/pressure")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, ConfigurablePolicyUnknownPathsRequireAuth) { + ConfigurableAuthRequirementPolicy policy(auth_requirements_); + + // Unknown paths default to requiring authentication + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/unknown/path")); + EXPECT_TRUE(policy.requires_authentication("POST", "/api/v1/secret")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, ConfigurablePolicyLongestMatchWins) { + // Create a policy where /api/v1/public is public, but /api/v1/public/secret/* requires auth + std::unordered_map requirements = { + {"/api/v1/public", AuthRequirement::NONE}, + {"/api/v1/public/*", AuthRequirement::NONE}, + {"/api/v1/public/secret/*", AuthRequirement::ALL}, + }; + ConfigurableAuthRequirementPolicy policy(requirements); + + // /api/v1/public/anything should be public + EXPECT_FALSE(policy.requires_authentication("GET", "/api/v1/public/info")); + + // /api/v1/public/secret/data should require auth (longest match wins) + EXPECT_TRUE(policy.requires_authentication("GET", "/api/v1/public/secret/data")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, FactoryCreatesNoAuthPolicy) { + AuthConfig config; + config.enabled = false; + + auto policy = AuthRequirementPolicyFactory::create(config); + ASSERT_NE(policy, nullptr); + + // Should be NoAuthRequirementPolicy + EXPECT_FALSE(policy->requires_authentication("POST", "/api/v1/admin/users")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, FactoryCreatesWriteOnlyFromConfig) { + AuthConfig config; + config.enabled = true; + config.require_auth_for = AuthRequirement::WRITE; + + auto policy = AuthRequirementPolicyFactory::create(config); + ASSERT_NE(policy, nullptr); + + // Should be WriteOnlyAuthRequirementPolicy + EXPECT_FALSE(policy->requires_authentication("GET", "/api/v1/version")); + EXPECT_TRUE(policy->requires_authentication("POST", "/api/v1/admin/users")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, FactoryCreatesAllAuthFromConfig) { + AuthConfig config; + config.enabled = true; + config.require_auth_for = AuthRequirement::ALL; + + auto policy = AuthRequirementPolicyFactory::create(config); + ASSERT_NE(policy, nullptr); + + // Should be AllAuthRequirementPolicy + EXPECT_TRUE(policy->requires_authentication("GET", "/api/v1/version")); + EXPECT_TRUE(policy->requires_authentication("POST", "/api/v1/admin/users")); +} + +// @verifies REQ_INTEROP_086 +TEST_F(AuthRequirementPolicyTest, PolicyDescriptions) { + NoAuthRequirementPolicy no_auth; + AllAuthRequirementPolicy all_auth; + WriteOnlyAuthRequirementPolicy write_only; + ConfigurableAuthRequirementPolicy configurable(auth_requirements_); + + EXPECT_FALSE(no_auth.description().empty()); + EXPECT_FALSE(all_auth.description().empty()); + EXPECT_FALSE(write_only.description().empty()); + EXPECT_FALSE(configurable.description().empty()); + + // Descriptions should be unique + EXPECT_NE(no_auth.description(), all_auth.description()); + EXPECT_NE(write_only.description(), configurable.description()); +} + int main(int argc, char ** argv) { testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS();