diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index cea633d..d010081 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -47,11 +47,10 @@ RUN apt-get update \ # Create a non-root user to use ARG USERNAME=vscode -ARG USER_UID=1000 -ARG USER_GID=$USER_UID -RUN groupadd --gid $USER_GID $USERNAME \ - && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \ +# Create user and group without forcing specific UID/GID +RUN groupadd $USERNAME \ + && useradd -g $USERNAME -m $USERNAME \ && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ && chmod 0440 /etc/sudoers.d/$USERNAME diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c3b4cda..b4a9529 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -26,7 +26,7 @@ // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "git submodule update --init --recursive", + "postCreateCommand": "git config --global --add safe.directory /workspaces/scitokens-cpp && git submodule update --init --recursive", // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. "remoteUser": "vscode", diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..369efb8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,146 @@ +# GitHub Copilot Instructions for scitokens-cpp + +## Project Overview + +scitokens-cpp is a C++ library for creating and validating SciTokens (JWT-based authorization tokens for scientific computing). The library uses JWT-cpp for token operations and supports OIDC discovery with JWKS for public key distribution. + +## Building the Project + +### Prerequisites + +- CMake 3.10 or later +- C++11 compatible compiler (gcc, clang) +- OpenSSL 1.1.1 or later (3.0+ recommended) +- libuuid +- sqlite3 +- jwt-cpp (included as vendor submodule) + +### Build Commands + +```bash +# Create build directory +mkdir -p build +cd build + +# Configure with CMake (enable tests with -DSCITOKENS_BUILD_UNITTESTS=ON) +cmake .. -DSCITOKENS_BUILD_UNITTESTS=ON + +# Build all targets +make + +# Install (optional) +sudo make install +``` + +### CMake Build Options + +- Tests are **disabled by default** - use `-DSCITOKENS_BUILD_UNITTESTS=ON` to enable +- Build produces: + - `libSciTokens.so` - Main library + - `scitokens-test` - Unit tests (Google Test) + - `scitokens-integration-test` - Integration tests with real HTTPS server + - `scitokens-generate-jwks` - JWKS generation utility + - Command-line tools: `scitokens-verify`, `scitokens-create`, `scitokens-list-access`, `scitokens-test-access` + +## Running Tests + +### Unit Tests + +```bash +cd build +./scitokens-test +``` + +Expected: 29 unit tests should pass + +### Integration Tests + +Integration tests use CTest fixtures with setup/teardown phases: + +```bash +cd build/test +ctest --output-on-failure +``` + +Or run specific test phases: +```bash +ctest -R integration::setup # Start HTTPS JWKS server +ctest -R integration::test # Run integration tests +ctest -R integration::teardown # Stop server +``` + +**Integration test infrastructure:** +- `test/jwks_server.py` - Python HTTPS server with OIDC discovery and JWKS endpoints +- `test/integration-test-setup.sh` - Generates TLS certificates and starts server +- `test/integration-test-teardown.sh` - Stops server gracefully +- `test/integration_test.cpp` - C++ tests using real HTTPS connections + +Expected: 3 integration tests should pass (total time ~1-2 seconds) + +### All Tests + +```bash +cd build/test +ctest --output-on-failure +``` + +Expected: 32 total tests (29 unit + 3 integration) + +## Code Style + +- C++11 standard +- Use `clang-format` for formatting (configuration in project root) +- Format before committing: `clang-format -i src/*.cpp src/*.h` + +## Testing Infrastructure Details + +### JWKS Server (test/jwks_server.py) + +Python HTTPS server that provides: +- `/.well-known/openid-configuration` - OIDC discovery document +- `/oauth2/certs` - JWKS public key endpoint + +Server features: +- HTTP/1.1 with keep-alive support +- TLS 1.2+ with self-signed certificates +- Graceful shutdown with SIGTERM +- Logs to `build/tests/integration/server.log` + +### Integration Test Flow + +1. **Setup**: Generate EC P-256 key pair, create JWKS, generate TLS certificates, start HTTPS server +2. **Test**: Create tokens, verify with JWKS discovery, test dynamic issuer enforcement +3. **Teardown**: Stop server, print logs if tests failed + +### Debugging Integration Tests + +If integration tests fail: +1. Check server log: `cat build/tests/integration/server.log` +2. Verify server started: `cat build/tests/integration/server_ready` +3. Test HTTPS manually: `curl -k https://localhost:/.well-known/openid-configuration` + +## Key Files + +- `src/scitokens.cpp`, `src/scitokens.h` - Main library API +- `src/generate_jwks.cpp` - JWKS generation (EC P-256 keys) +- `src/scitokens_internal.cpp` - Token validation and OIDC discovery +- `src/scitokens_cache.cpp` - JWKS caching +- `test/integration_test.cpp` - End-to-end integration tests +- `test/main.cpp` - Google Test unit tests + +## Development Workflow + +1. Make code changes +2. Build: `cd build && cmake .. && make` +3. Run unit tests: `./scitokens-test` +4. Run integration tests: `cd test && ctest --output-on-failure` +5. Format code: `clang-format -i ` +6. Commit with descriptive message + +## CI/CD + +GitHub Actions runs tests on: +- Ubuntu 22.04 (OpenSSL 3.0.2) +- Ubuntu 24.04 (OpenSSL 3.0.13) + +Integration tests verify TLS compatibility across OpenSSL versions. diff --git a/.gitignore b/.gitignore index c9ec4c9..f642ca5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ build _codeql_build_dir _codeql_detected_source_root +*.pyc +__pycache__/ diff --git a/src/generate_jwks.cpp b/src/generate_jwks.cpp index 0436223..2e1d941 100644 --- a/src/generate_jwks.cpp +++ b/src/generate_jwks.cpp @@ -128,29 +128,33 @@ std::string base64url_encode(const unsigned char *data, size_t len) { bool extract_ec_coordinates(EVP_PKEY *pkey, std::string &x_coord, std::string &y_coord) { #if OPENSSL_VERSION_NUMBER >= 0x30000000L - size_t pub_key_len = 0; - - if (EVP_PKEY_get_octet_string_param(pkey, OSSL_PKEY_PARAM_PUB_KEY, nullptr, - 0, &pub_key_len) != 1) { + // For OpenSSL 3.0+, use the BIGNUM parameter API which is more reliable + BIGNUM *x_bn = nullptr; + BIGNUM *y_bn = nullptr; + + if (EVP_PKEY_get_bn_param(pkey, OSSL_PKEY_PARAM_EC_PUB_X, &x_bn) != 1 || + EVP_PKEY_get_bn_param(pkey, OSSL_PKEY_PARAM_EC_PUB_Y, &y_bn) != 1) { + BN_free(x_bn); + BN_free(y_bn); return false; } - std::unique_ptr pub_key_buf( - new unsigned char[pub_key_len]); + std::unique_ptr x(x_bn, BN_free); + std::unique_ptr y(y_bn, BN_free); - if (EVP_PKEY_get_octet_string_param(pkey, OSSL_PKEY_PARAM_PUB_KEY, - pub_key_buf.get(), pub_key_len, - &pub_key_len) != 1) { - return false; - } + // Convert BIGNUMs to fixed-size byte arrays (32 bytes for P-256) + unsigned char x_buf[32] = {0}; + unsigned char y_buf[32] = {0}; - // For uncompressed EC point format: 0x04 || X || Y - if (pub_key_len != 65 || pub_key_buf[0] != 0x04) { - return false; - } + int x_len = BN_num_bytes(x.get()); + int y_len = BN_num_bytes(y.get()); - x_coord = base64url_encode(pub_key_buf.get() + 1, 32); - y_coord = base64url_encode(pub_key_buf.get() + 33, 32); + // Pad with zeros on the left if necessary + BN_bn2bin(x.get(), x_buf + (32 - x_len)); + BN_bn2bin(y.get(), y_buf + (32 - y_len)); + + x_coord = base64url_encode(x_buf, 32); + y_coord = base64url_encode(y_buf, 32); #else std::unique_ptr ec_key( EVP_PKEY_get1_EC_KEY(pkey), EC_KEY_free); @@ -167,8 +171,18 @@ bool extract_ec_coordinates(EVP_PKEY *pkey, std::string &x_coord, std::unique_ptr x(BN_new(), BN_free); std::unique_ptr y(BN_new(), BN_free); - if (!EC_POINT_get_affine_coordinates_GFp(group, pub_key, x.get(), y.get(), - nullptr)) { + // Use EC_POINT_get_affine_coordinates for OpenSSL 1.1.1+ + // or EC_POINT_get_affine_coordinates_GFp for older versions + int result = 0; +#if OPENSSL_VERSION_NUMBER >= 0x10101000L + result = EC_POINT_get_affine_coordinates(group, pub_key, x.get(), y.get(), + nullptr); +#else + result = EC_POINT_get_affine_coordinates_GFp(group, pub_key, x.get(), + y.get(), nullptr); +#endif + + if (result != 1) { return false; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 772ba1c..56f3494 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -19,3 +19,52 @@ add_test( COMMAND ${CMAKE_CURRENT_BINARY_DIR}/scitokens-gtest ) + +# Integration test executable +add_executable(scitokens-integration-test integration_test.cpp) +if( NOT SCITOKENS_EXTERNAL_GTEST ) + add_dependencies(scitokens-integration-test gtest) +endif() +target_link_libraries(scitokens-integration-test SciTokens "${LIBGTEST}" pthread) + +# Integration test fixture - setup +add_test( + NAME + integration::setup + COMMAND + ${CMAKE_CURRENT_SOURCE_DIR}/integration-test-setup.sh integration +) + +set_tests_properties(integration::setup + PROPERTIES + FIXTURES_SETUP integration + ENVIRONMENT "BINARY_DIR=${CMAKE_BINARY_DIR};SOURCE_DIR=${PROJECT_SOURCE_DIR}" +) + +# Integration test fixture - teardown +add_test( + NAME + integration::teardown + COMMAND + ${CMAKE_CURRENT_SOURCE_DIR}/integration-test-teardown.sh integration +) + +set_tests_properties(integration::teardown + PROPERTIES + FIXTURES_CLEANUP integration + ENVIRONMENT "BINARY_DIR=${CMAKE_BINARY_DIR}" +) + +# Integration test +add_test( + NAME + integration::test + COMMAND + ${CMAKE_CURRENT_BINARY_DIR}/scitokens-integration-test +) + +set_tests_properties(integration::test + PROPERTIES + FIXTURES_REQUIRED integration + ENVIRONMENT "BINARY_DIR=${CMAKE_BINARY_DIR};CURL_CA_BUNDLE=${CMAKE_BINARY_DIR}/tests/integration/current/ca-cert.pem" +) diff --git a/test/README_INTEGRATION.md b/test/README_INTEGRATION.md new file mode 100644 index 0000000..bc3f1e4 --- /dev/null +++ b/test/README_INTEGRATION.md @@ -0,0 +1,137 @@ +# Integration Tests + +This directory contains integration tests for scitokens-cpp that use a full end-to-end testing environment. + +## Overview + +The integration test framework provides: + +1. **TLS Infrastructure**: Automatic generation of CA certificates and server certificates for HTTPS testing +2. **Key Management**: EC key generation and JWKS creation for token signing +3. **Test Server**: Python-based HTTPS server hosting JWKS and supporting OIDC discovery +4. **CTest Fixtures**: Automated setup and teardown using CTest fixture framework + +## Architecture + +The integration tests use a CTest fixture pattern with three components: + +### Setup (`integration-test-setup.sh`) + +The setup script: +- Creates a temporary test run directory +- Generates TLS certificates (CA and server certificate) +- Generates EC P-256 signing keys +- Converts public key to JWKS format +- Starts a Python HTTPS server on a dynamic port (port 0) +- Writes environment configuration to `build/tests/integration/setup.sh` + +The setup script writes the following information to `setup.sh`: +- `ISSUER_URL`: The HTTPS URL of the test issuer (e.g., `https://localhost:12345`) +- `SERVER_PID`: Process ID of the running server +- `CA_CERT`: Path to the CA certificate for TLS verification +- `SIGNING_KEY`: Path to the EC private key +- `SIGNING_PUB`: Path to the EC public key +- `JWKS_FILE`: Path to the JWKS file + +### Test (`integration_test.cpp`) + +The integration test program: +- Reads configuration from `build/tests/integration/setup.sh` +- Configures scitokens to trust the test CA certificate +- Creates and signs tokens using the test issuer +- Verifies tokens using JWKS discovery from the test server +- Tests the enforcer functionality with dynamically issued tokens + +Three test cases are included: +1. **CreateAndSignToken**: Verifies basic token creation and signing +2. **VerifyTokenWithJWKSDiscovery**: Tests token verification using JWKS discovery from the HTTPS server +3. **EnforcerWithDynamicIssuer**: Tests the enforcer API with tokens from the dynamic test issuer + +### Teardown (`integration-test-teardown.sh`) + +The teardown script: +- Reads the server PID from `setup.sh` +- Gracefully stops the server (SIGTERM, then SIGKILL if needed) +- Cleans up the test environment + +## Running the Tests + +### Build with Integration Tests + +```bash +mkdir build +cd build +cmake -DSCITOKENS_BUILD_UNITTESTS=ON .. +make +``` + +### Run Integration Tests Only + +```bash +ctest -R integration --output-on-failure +``` + +### Run All Tests + +```bash +ctest --output-on-failure +``` + +## Test Server + +The Python test server (`jwks_server.py`) implements: + +- **OIDC Discovery**: Serves `.well-known/openid-configuration` +- **JWKS Endpoint**: Serves the JWKS at `/oauth2/certs` +- **HTTPS Support**: Uses TLS with generated certificates +- **Dynamic Port Allocation**: Binds to port 0 for automatic port selection + +The server is designed to be minimal and focused solely on the requirements for integration testing. + +## Requirements + +- Python 3.6+ (for the test server) +- OpenSSL command-line tools +- scitokens-generate-jwks (built from this repository) + +## Troubleshooting + +### Server fails to start + +Check that Python 3 is available: +```bash +python3 --version +``` + +### Missing scitokens-generate-jwks + +If the setup script fails because `scitokens-generate-jwks` is not found, rebuild the project: +```bash +make scitokens-generate-jwks +``` + +### TLS certificate errors + +The test automatically generates self-signed certificates. The scitokens library is configured to trust the test CA certificate via the `tls.ca_file` configuration option. + +### Server doesn't shut down cleanly + +The teardown script will wait up to 10 seconds for graceful shutdown, then send SIGKILL. If tests are interrupted, you may need to manually kill the server process: +```bash +ps aux | grep jwks_server +kill +``` + +## Design Decisions + +1. **Dynamic Port Allocation**: Using port 0 ensures tests can run in parallel and don't conflict with existing services. + +2. **Self-Signed Certificates**: Generated on-the-fly to avoid committing secrets to the repository and to test the full TLS stack. + +3. **HTTPS Required**: SciTokens requires HTTPS issuers, so we use HTTPS even for local testing. + +4. **CTest Fixtures**: Using CTest's fixture framework ensures proper setup and teardown ordering, even when tests run in parallel. + +5. **Detached Server Process**: The server runs detached from the shell to avoid blocking the setup script. + +6. **Minimal Python Server**: Using Python's built-in `http.server` keeps dependencies minimal. JWKS generation is handled by the `scitokens-generate-jwks` tool built from this repository. diff --git a/test/integration-test-setup.sh b/test/integration-test-setup.sh new file mode 100755 index 0000000..249c61d --- /dev/null +++ b/test/integration-test-setup.sh @@ -0,0 +1,179 @@ +#!/bin/bash +# +# Setup script for scitokens-cpp integration tests +# Creates TLS certificates, keys, JWKS, and launches test server +# + +set -e + +TEST_NAME=${1:-integration} + +if [ -z "$BINARY_DIR" ]; then + echo "\$BINARY_DIR environment variable is not set; cannot run test" + exit 1 +fi + +if [ -z "$SOURCE_DIR" ]; then + echo "\$SOURCE_DIR environment variable is not set; cannot run test" + exit 1 +fi + +echo "Setting up integration test environment for $TEST_NAME" + +# Create test directory +TEST_DIR="$BINARY_DIR/tests/$TEST_NAME" +mkdir -p "$TEST_DIR" +RUNDIR=$(mktemp -d -p "$TEST_DIR" test_run.XXXXXXXX) +chmod 0755 "$RUNDIR" + +if [ ! -d "$RUNDIR" ]; then + echo "Failed to create test run directory; cannot run test" + exit 1 +fi + +echo "Using $RUNDIR as the test run directory" +cd "$RUNDIR" + +# Create link to rundir at fixed location for tests to find +if [ -L "$TEST_DIR/current" ]; then + rm "$TEST_DIR/current" +fi +ln -sf "$RUNDIR" "$TEST_DIR/current" + +############################ +# Generate TLS certificates +############################ +echo "Generating TLS CA and host certificate..." + +# Generate CA key and certificate +openssl genrsa -out ca-key.pem 2048 2>/dev/null +openssl req -new -x509 -days 365 -key ca-key.pem -out ca-cert.pem \ + -subj "/C=US/ST=Test/L=Test/O=SciTokens Test/CN=Test CA" 2>/dev/null + +# Generate server key and certificate +openssl genrsa -out server-key.pem 2048 2>/dev/null +openssl req -new -key server-key.pem -out server.csr \ + -subj "/C=US/ST=Test/L=Test/O=SciTokens Test/CN=localhost" 2>/dev/null + +# Create server certificate signed by CA with proper extensions +cat > server-cert-ext.cnf </dev/null + +echo "TLS certificates created" + +########################## +# Generate signing keys and JWKS +########################## +echo "Generating EC signing keys and JWKS..." + +# Use scitokens-generate-jwks to create keys and JWKS +if [ ! -f "$BINARY_DIR/scitokens-generate-jwks" ]; then + echo "Error: scitokens-generate-jwks not found in $BINARY_DIR" + echo "Please build the project first with: make scitokens-generate-jwks" + exit 1 +fi + +"$BINARY_DIR/scitokens-generate-jwks" \ + --kid "test-key-1" \ + --jwks jwks.json \ + --private signing-key.pem \ + --public signing-pub.pem + +if [ ! -f jwks.json ]; then + echo "Failed to generate JWKS" + exit 1 +fi + +echo "Signing keys and JWKS created" + +########################## +# Start Python web server +########################## +echo "Starting JWKS web server..." + +# Clean up old server ready file to avoid stale data +READY_FILE="$TEST_DIR/server_ready" +rm -f "$READY_FILE" + +# Start server in background, detached from terminal +python3 "$SOURCE_DIR/test/jwks_server.py" \ + --jwks "$RUNDIR/jwks.json" \ + --build-dir "$BINARY_DIR" \ + --test-name "$TEST_NAME" \ + --cert "$RUNDIR/server-cert.pem" \ + --key "$RUNDIR/server-key.pem" \ + /dev/null 2>&1 & + +SERVER_PID=$! +echo "Server PID: $SERVER_PID" + +# Wait for server to be ready +TIMEOUT=10 +ELAPSED=0 + +while [ ! -f "$READY_FILE" ]; do + sleep 0.05 + ELAPSED=$((ELAPSED + 1)) + if [ $ELAPSED -ge $((TIMEOUT * 20)) ]; then + echo "Timeout waiting for server to start" + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + + # Check if server process is still running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "Server process died unexpectedly" + exit 1 + fi +done + +echo "Server ready" + +# Read server info from ready file +. "$READY_FILE" + +# Verify we got the PID from the ready file +if [ -z "$PID" ]; then + echo "Failed to get PID from server ready file" + kill $SERVER_PID 2>/dev/null || true + exit 1 +fi + +if [ -z "$ISSUER_URL" ]; then + echo "Failed to get issuer URL from server" + kill $PID 2>/dev/null || true + exit 1 +fi + +echo "Issuer URL: $ISSUER_URL" + +########################## +# Write setup.sh +########################## +cat > "$TEST_DIR/setup.sh" </dev/null; then + echo "Server process was already stopped" + exit 0 +fi + +# Send SIGTERM to server +kill "$SERVER_PID" 2>/dev/null || true + +# Wait for server to stop (with timeout) +TIMEOUT=5 +ELAPSED=0 +while kill -0 "$SERVER_PID" 2>/dev/null; do + sleep 0.1 + ELAPSED=$((ELAPSED + 1)) + if [ $ELAPSED -ge $((TIMEOUT * 10)) ]; then + echo "Timeout waiting for server to stop, sending SIGKILL" + kill -9 "$SERVER_PID" 2>/dev/null || true + sleep 0.1 + break + fi +done + +# Verify server is stopped (best effort - don't fail if already gone) +if kill -0 "$SERVER_PID" 2>/dev/null; then + echo "Warning: Server may still be running" +else + echo "Server stopped successfully" +fi + +# Print server log if it exists +SERVER_LOG="$TEST_DIR/server.log" +if [ -f "$SERVER_LOG" ]; then + echo "" + echo "=== Server Log ===" + cat "$SERVER_LOG" + echo "==================" +fi diff --git a/test/integration_test.cpp b/test/integration_test.cpp new file mode 100644 index 0000000..4b308ae --- /dev/null +++ b/test/integration_test.cpp @@ -0,0 +1,388 @@ +#include "../src/scitokens.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Helper to read environment variables from setup.sh +class TestEnvironment { + public: + static TestEnvironment &getInstance() { + static TestEnvironment instance; + return instance; + } + + bool load() { + if (loaded_) + return true; + + const char *binary_dir = getenv("BINARY_DIR"); + if (!binary_dir) { + std::cerr << "BINARY_DIR not set" << std::endl; + return false; + } + + std::string setup_file = + std::string(binary_dir) + "/tests/integration/setup.sh"; + std::ifstream file(setup_file); + if (!file.is_open()) { + std::cerr << "Could not open " << setup_file << std::endl; + return false; + } + + std::string line; + while (std::getline(file, line)) { + // Skip comments and empty lines + if (line.empty() || line[0] == '#') + continue; + + // Parse KEY=VALUE + auto pos = line.find('='); + if (pos != std::string::npos) { + std::string key = line.substr(0, pos); + std::string value = line.substr(pos + 1); + vars_[key] = value; + } + } + + loaded_ = true; + return true; + } + + std::string get(const std::string &key) const { + auto it = vars_.find(key); + if (it != vars_.end()) { + return it->second; + } + return ""; + } + + private: + TestEnvironment() : loaded_(false) {} + bool loaded_; + std::map vars_; +}; + +class IntegrationTest : public ::testing::Test { + protected: + void SetUp() override { + ASSERT_TRUE(TestEnvironment::getInstance().load()) + << "Failed to load test environment"; + + issuer_url_ = TestEnvironment::getInstance().get("ISSUER_URL"); + signing_key_file_ = TestEnvironment::getInstance().get("SIGNING_KEY"); + signing_pub_file_ = TestEnvironment::getInstance().get("SIGNING_PUB"); + std::string ca_cert_file = + TestEnvironment::getInstance().get("CA_CERT"); + + ASSERT_FALSE(issuer_url_.empty()) << "ISSUER_URL not set"; + ASSERT_FALSE(signing_key_file_.empty()) << "SIGNING_KEY not set"; + ASSERT_FALSE(signing_pub_file_.empty()) << "SIGNING_PUB not set"; + ASSERT_FALSE(ca_cert_file.empty()) << "CA_CERT not set"; + + // Set the TLS CA file for scitokens to use + char *err_msg = nullptr; + int rv = scitoken_config_set_str("tls.ca_file", ca_cert_file.c_str(), + &err_msg); + ASSERT_EQ(rv, 0) << "Failed to set TLS CA file: " + << (err_msg ? err_msg : "unknown error"); + if (err_msg) + free(err_msg); + + // Load keys + std::ifstream priv_ifs(signing_key_file_); + ASSERT_TRUE(priv_ifs.is_open()) + << "Failed to open " << signing_key_file_; + private_key_ = std::string(std::istreambuf_iterator(priv_ifs), + std::istreambuf_iterator()); + + std::ifstream pub_ifs(signing_pub_file_); + ASSERT_TRUE(pub_ifs.is_open()) + << "Failed to open " << signing_pub_file_; + public_key_ = std::string(std::istreambuf_iterator(pub_ifs), + std::istreambuf_iterator()); + } + + std::string issuer_url_; + std::string signing_key_file_; + std::string signing_pub_file_; + std::string private_key_; + std::string public_key_; +}; + +TEST_F(IntegrationTest, CreateAndSignToken) { + char *err_msg = nullptr; + + // Create a key + std::unique_ptr key( + scitoken_key_create("test-key-1", "ES256", public_key_.c_str(), + private_key_.c_str(), &err_msg), + scitoken_key_destroy); + ASSERT_TRUE(key.get() != nullptr) + << "Failed to create key: " << (err_msg ? err_msg : "unknown error"); + if (err_msg) + free(err_msg); + + // Create a token + std::unique_ptr token( + scitoken_create(key.get()), scitoken_destroy); + ASSERT_TRUE(token.get() != nullptr) << "Failed to create token"; + + // Set issuer + auto rv = scitoken_set_claim_string(token.get(), "iss", issuer_url_.c_str(), + &err_msg); + ASSERT_EQ(rv, 0) << "Failed to set issuer: " + << (err_msg ? err_msg : "unknown error"); + if (err_msg) + free(err_msg); + + // Set some claims + rv = + scitoken_set_claim_string(token.get(), "sub", "test-subject", &err_msg); + ASSERT_EQ(rv, 0) << "Failed to set subject: " + << (err_msg ? err_msg : "unknown error"); + if (err_msg) + free(err_msg); + + rv = + scitoken_set_claim_string(token.get(), "scope", "read:/test", &err_msg); + ASSERT_EQ(rv, 0) << "Failed to set scope: " + << (err_msg ? err_msg : "unknown error"); + if (err_msg) + free(err_msg); + + // Set lifetime + scitoken_set_lifetime(token.get(), 3600); + + // Serialize the token + char *token_value = nullptr; + rv = scitoken_serialize(token.get(), &token_value, &err_msg); + ASSERT_EQ(rv, 0) << "Failed to serialize token: " + << (err_msg ? err_msg : "unknown error"); + if (err_msg) + free(err_msg); + + ASSERT_TRUE(token_value != nullptr); + std::unique_ptr token_value_ptr(token_value, free); + + EXPECT_GT(strlen(token_value), 50) << "Token seems too short"; + + std::cout << "Created token: " << token_value << std::endl; +} + +TEST_F(IntegrationTest, VerifyTokenWithJWKSDiscovery) { + char *err_msg = nullptr; + + // Create a key + std::unique_ptr key( + scitoken_key_create("test-key-1", "ES256", public_key_.c_str(), + private_key_.c_str(), &err_msg), + scitoken_key_destroy); + ASSERT_TRUE(key.get() != nullptr); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + // Create and sign a token + std::unique_ptr token( + scitoken_create(key.get()), scitoken_destroy); + ASSERT_TRUE(token.get() != nullptr); + + auto rv = scitoken_set_claim_string(token.get(), "iss", issuer_url_.c_str(), + &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + rv = + scitoken_set_claim_string(token.get(), "sub", "test-subject", &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + rv = + scitoken_set_claim_string(token.get(), "scope", "read:/test", &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + scitoken_set_lifetime(token.get(), 3600); + + char *token_value = nullptr; + rv = scitoken_serialize(token.get(), &token_value, &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + std::unique_ptr token_value_ptr(token_value, free); + + // Now verify the token using JWKS discovery + std::unique_ptr verify_token( + scitoken_create(nullptr), scitoken_destroy); + ASSERT_TRUE(verify_token.get() != nullptr); + + // This should fetch the JWKS from the server via discovery + rv = scitoken_deserialize_v2(token_value, verify_token.get(), nullptr, + &err_msg); + ASSERT_EQ(rv, 0) << "Failed to verify token: " + << (err_msg ? err_msg : "unknown error"); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + // Verify we can read back the claims + char *value = nullptr; + rv = scitoken_get_claim_string(verify_token.get(), "iss", &value, &err_msg); + ASSERT_EQ(rv, 0); + ASSERT_TRUE(value != nullptr); + std::unique_ptr value_ptr(value, free); + EXPECT_EQ(std::string(value), issuer_url_); + + value_ptr.reset(); + rv = scitoken_get_claim_string(verify_token.get(), "sub", &value, &err_msg); + ASSERT_EQ(rv, 0); + ASSERT_TRUE(value != nullptr); + value_ptr.reset(value); + EXPECT_STREQ(value, "test-subject"); + + value_ptr.reset(); + rv = scitoken_get_claim_string(verify_token.get(), "scope", &value, + &err_msg); + ASSERT_EQ(rv, 0); + ASSERT_TRUE(value != nullptr); + value_ptr.reset(value); + EXPECT_STREQ(value, "read:/test"); +} + +TEST_F(IntegrationTest, EnforcerWithDynamicIssuer) { + char *err_msg = nullptr; + + // Create a key and token + std::unique_ptr key( + scitoken_key_create("test-key-1", "ES256", public_key_.c_str(), + private_key_.c_str(), &err_msg), + scitoken_key_destroy); + ASSERT_TRUE(key.get() != nullptr); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + std::unique_ptr token( + scitoken_create(key.get()), scitoken_destroy); + ASSERT_TRUE(token.get() != nullptr); + + auto rv = scitoken_set_claim_string(token.get(), "iss", issuer_url_.c_str(), + &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + rv = scitoken_set_claim_string(token.get(), "aud", + "https://test.example.com", &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + rv = + scitoken_set_claim_string(token.get(), "scope", "read:/data", &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + rv = + scitoken_set_claim_string(token.get(), "ver", "scitoken:2.0", &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + scitoken_set_lifetime(token.get(), 3600); + + char *token_value = nullptr; + rv = scitoken_serialize(token.get(), &token_value, &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + std::unique_ptr token_value_ptr(token_value, free); + + // Deserialize for verification + std::unique_ptr verify_token( + scitoken_create(nullptr), scitoken_destroy); + ASSERT_TRUE(verify_token.get() != nullptr); + + rv = scitoken_deserialize_v2(token_value, verify_token.get(), nullptr, + &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + // Create enforcer + const char *audiences[] = {"https://test.example.com", nullptr}; + auto enforcer = enforcer_create(issuer_url_.c_str(), audiences, &err_msg); + ASSERT_TRUE(enforcer != nullptr); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + // Test with valid ACL + Acl acl; + acl.authz = "read"; + acl.resource = "/data/file.txt"; + + rv = enforcer_test(enforcer, verify_token.get(), &acl, &err_msg); + EXPECT_EQ(rv, 0) << "Enforcer should allow read on /data/file.txt"; + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + // Test with invalid ACL (wrong authz) + acl.authz = "write"; + acl.resource = "/data/file.txt"; + + rv = enforcer_test(enforcer, verify_token.get(), &acl, &err_msg); + EXPECT_NE(rv, 0) << "Enforcer should deny write access"; + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + enforcer_destroy(enforcer); +} + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/jwks_server.py b/test/jwks_server.py new file mode 100755 index 0000000..b859490 --- /dev/null +++ b/test/jwks_server.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +Simple Python web server that hosts JWKS and supports OIDC discovery. +Used for integration testing of scitokens-cpp. +""" + +import argparse +import json +import os +import signal +import socket +import ssl +import sys +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path + + +class JWKSHandler(BaseHTTPRequestHandler): + """HTTP handler for JWKS and discovery endpoints.""" + + # Use HTTP/1.1 for proper connection handling + protocol_version = 'HTTP/1.1' + + def log_message(self, format, *args): + """Override to log to file instead of stderr.""" + if hasattr(self.server, 'log_file'): + with open(self.server.log_file, 'a') as f: + f.write("%s - - [%s] %s\n" % ( + self.address_string(), + self.log_date_time_string(), + format % args)) + + def do_GET(self): + """Handle GET requests for JWKS and discovery.""" + if self.path == '/.well-known/openid-configuration': + self.serve_discovery() + elif self.path == '/oauth2/certs' or self.path == '/jwks': + self.serve_jwks() + else: + self.send_error(404, "Not Found") + + def serve_discovery(self): + """Serve OIDC discovery document.""" + issuer = self.server.issuer_url + discovery = { + "issuer": issuer, + "jwks_uri": f"{issuer}/oauth2/certs", + "token_endpoint": f"{issuer}/token", + "authorization_endpoint": f"{issuer}/authorize", + } + + content = json.dumps(discovery).encode() + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(content))) + self.end_headers() + self.wfile.write(content) + + def serve_jwks(self): + """Serve JWKS document.""" + with open(self.server.jwks_file, 'r') as f: + jwks_content = f.read() + + content = jwks_content.encode() + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(content))) + self.end_headers() + self.wfile.write(content) + + +def main(): + parser = argparse.ArgumentParser(description='JWKS test server') + parser.add_argument('--jwks', required=True, help='Path to JWKS file') + parser.add_argument('--build-dir', required=True, help='Build directory') + parser.add_argument('--test-name', default='integration', help='Test name') + parser.add_argument('--cert', help='Path to TLS certificate file') + parser.add_argument('--key', help='Path to TLS key file') + args = parser.parse_args() + + # Determine if we're using HTTPS + use_https = args.cert and args.key + protocol = "https" if use_https else "http" + + # Create test directory + test_dir = Path(args.build_dir) / 'tests' / args.test_name + test_dir.mkdir(parents=True, exist_ok=True) + + # Create ready file to signal server is ready + ready_file = test_dir / 'server_ready' + log_file = test_dir / 'server.log' + + # Setup HTTP server - bind to port 0 to get a free port automatically + server = HTTPServer(('localhost', 0), JWKSHandler) + server.jwks_file = args.jwks + server.log_file = str(log_file) + + # Setup TLS if certificates provided + if use_https: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(args.cert, args.key) + # Set minimum TLS version to 1.2 for security + context.minimum_version = ssl.TLSVersion.TLSv1_2 + # Set cipher suites for OpenSSL 3.0.2 compatibility + # SECLEVEL=1 allows 2048-bit RSA and SHA-1 for test certificates + try: + context.set_ciphers('DEFAULT:@SECLEVEL=1') + except ssl.SSLError: + # Fallback for older Python/OpenSSL + context.set_ciphers('DEFAULT') + # Disable TLS session tickets to avoid issues with session resumption + context.options |= ssl.OP_NO_TICKET + # Allow self-signed certificates for testing + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + server.socket = context.wrap_socket(server.socket, server_side=True) + + # Get the actual port that was assigned + port = server.server_address[1] + issuer_url = f"{protocol}://localhost:{port}" + server.issuer_url = issuer_url + + # Write server info to ready file + with open(ready_file, 'w') as f: + f.write(f"PID={os.getpid()}\n") + f.write(f"ISSUER_URL={issuer_url}\n") + f.write(f"PORT={port}\n") + + print(f"Server started on {issuer_url}", flush=True) + print(f"Server PID: {os.getpid()}", flush=True) + print(f"Server ready file: {ready_file}", flush=True) + + # Handle shutdown gracefully - set a flag that will be checked + shutdown_requested = [False] + + def signal_handler(signum, frame): + print("Shutting down server...", flush=True) + shutdown_requested[0] = True + # Shutdown needs to be called from a different thread or we need to exit + # Using os._exit to immediately terminate + os._exit(0) + + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == '__main__': + main()