diff --git a/open-agent-auth-integration-tests/pom.xml b/open-agent-auth-integration-tests/pom.xml index 2a53662..efaacd5 100644 --- a/open-agent-auth-integration-tests/pom.xml +++ b/open-agent-auth-integration-tests/pom.xml @@ -141,10 +141,11 @@ org.apache.maven.plugins maven-surefire-plugin - + **/*E2ETest.java **/*IntegrationTest.java + **/*ConformanceTest.java @@ -171,5 +172,22 @@ + + + protocol-conformance + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*ConformanceTest.java + + + + + + \ No newline at end of file diff --git a/open-agent-auth-integration-tests/scripts/diagnose-conformance-env.sh b/open-agent-auth-integration-tests/scripts/diagnose-conformance-env.sh new file mode 100755 index 0000000..fd0a357 --- /dev/null +++ b/open-agent-auth-integration-tests/scripts/diagnose-conformance-env.sh @@ -0,0 +1,271 @@ +#!/bin/bash + +############################################################################### +# Open Agent Auth - Protocol Conformance Environment Diagnostic Script +# +# This script checks if the environment is ready for protocol conformance testing. +# It validates all required services and protocol endpoints are accessible. +# +# Usage: ./scripts/diagnose-conformance-env.sh +# +# Author: Open Agent Auth Team +############################################################################### + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +echo -e "${CYAN}╔════════════════════════════════════════════════════════════════╗${NC}" +echo -e "${CYAN}║ ║${NC}" +echo -e "${CYAN}║ Protocol Conformance Environment Diagnostic Tool ║${NC}" +echo -e "${CYAN}║ ║${NC}" +echo -e "${CYAN}╚════════════════════════════════════════════════════════════════╝${NC}" +echo "" + +TOTAL_CHECKS=7 +ALL_PASSED=true + +# Check 1: Java version +echo -e "${BLUE}[Check 1/${TOTAL_CHECKS}] Java Version${NC}" +echo -e "${YELLOW}─────────────────────────────────────────────────────────────${NC}" + +JAVA_VERSION=$(java -version 2>&1 | head -n 1 | cut -d'"' -f2) +echo -e " Java version: ${JAVA_VERSION}" + +if [[ "$JAVA_VERSION" == "17"* ]] || [[ "$JAVA_VERSION" == "21"* ]]; then + echo -e " ${GREEN}✓ Java version is compatible${NC}" +else + echo -e " ${YELLOW}⚠ Java version may not be compatible (recommended: 17 or 21)${NC}" +fi +echo "" + +# Check 2: Maven +echo -e "${BLUE}[Check 2/${TOTAL_CHECKS}] Maven Installation${NC}" +echo -e "${YELLOW}─────────────────────────────────────────────────────────────${NC}" + +if command -v mvn &> /dev/null; then + MVN_VERSION=$(mvn -version 2>&1 | head -n 1) + echo -e " ${GREEN}✓ Maven is installed${NC}" + echo -e " $MVN_VERSION" +else + echo -e " ${RED}✗ Maven is not installed${NC}" + ALL_PASSED=false +fi +echo "" + +# Check 3: Required services (port listening) +echo -e "${BLUE}[Check 3/${TOTAL_CHECKS}] Required Services (Port Listening)${NC}" +echo -e "${YELLOW}─────────────────────────────────────────────────────────────${NC}" + +REQUIRED_SERVICES=( + "8082:Agent IDP" + "8083:Agent User IDP" + "8084:AS User IDP" + "8085:Authorization Server" + "8086:Resource Server" +) + +ALL_SERVICES_RUNNING=true +for service_info in "${REQUIRED_SERVICES[@]}"; do + port=$(echo "$service_info" | cut -d: -f1) + name=$(echo "$service_info" | cut -d: -f2) + + if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then + echo -e " ${GREEN}✓ $name (port $port) - Running${NC}" + else + echo -e " ${RED}✗ $name (port $port) - Not running${NC}" + ALL_SERVICES_RUNNING=false + ALL_PASSED=false + fi +done + +if [ "$ALL_SERVICES_RUNNING" = true ]; then + echo -e " ${GREEN}✓ All services are running${NC}" +else + echo -e " ${RED}✗ Some services are not running${NC}" +fi +echo "" + +# Check 4: JWKS endpoints (health check) +echo -e "${BLUE}[Check 4/${TOTAL_CHECKS}] JWKS Endpoints (Health Check)${NC}" +echo -e "${YELLOW}─────────────────────────────────────────────────────────────${NC}" + +JWKS_SERVICES=( + "8082:Agent IDP" + "8083:Agent User IDP" + "8084:AS User IDP" + "8085:Authorization Server" +) + +ALL_JWKS_HEALTHY=true +for service_info in "${JWKS_SERVICES[@]}"; do + port=$(echo "$service_info" | cut -d: -f1) + name=$(echo "$service_info" | cut -d: -f2) + + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port/.well-known/jwks.json" 2>/dev/null || echo "000") + + if [ "$HTTP_CODE" = "200" ]; then + echo -e " ${GREEN}✓ $name JWKS endpoint (HTTP $HTTP_CODE)${NC}" + else + echo -e " ${RED}✗ $name JWKS endpoint (HTTP $HTTP_CODE)${NC}" + ALL_JWKS_HEALTHY=false + ALL_PASSED=false + fi +done + +if [ "$ALL_JWKS_HEALTHY" = true ]; then + echo -e " ${GREEN}✓ All JWKS endpoints are accessible${NC}" +fi +echo "" + +# Check 5: OAuth 2.0 protocol endpoints +echo -e "${BLUE}[Check 5/${TOTAL_CHECKS}] OAuth 2.0 Protocol Endpoints${NC}" +echo -e "${YELLOW}─────────────────────────────────────────────────────────────${NC}" + +OAUTH_ENDPOINTS=( + "http://localhost:8085/.well-known/openid-configuration:OIDC Discovery" + "http://localhost:8085/.well-known/jwks.json:JWKS" +) + +ALL_OAUTH_HEALTHY=true +for endpoint_info in "${OAUTH_ENDPOINTS[@]}"; do + url=$(echo "$endpoint_info" | cut -d: -f1-3) + name=$(echo "$endpoint_info" | cut -d: -f4) + + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000") + + if [ "$HTTP_CODE" = "200" ]; then + echo -e " ${GREEN}✓ $name ($url) - HTTP $HTTP_CODE${NC}" + else + echo -e " ${RED}✗ $name ($url) - HTTP $HTTP_CODE${NC}" + ALL_OAUTH_HEALTHY=false + ALL_PASSED=false + fi +done + +# Token endpoint requires POST, so we check with a simple POST +TOKEN_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "http://localhost:8085/oauth2/token" 2>/dev/null || echo "000") +if [ "$TOKEN_CODE" = "400" ] || [ "$TOKEN_CODE" = "401" ]; then + echo -e " ${GREEN}✓ Token Endpoint (http://localhost:8085/oauth2/token) - HTTP $TOKEN_CODE (expected error for empty request)${NC}" +elif [ "$TOKEN_CODE" = "000" ]; then + echo -e " ${RED}✗ Token Endpoint (http://localhost:8085/oauth2/token) - Not reachable${NC}" + ALL_OAUTH_HEALTHY=false + ALL_PASSED=false +else + echo -e " ${YELLOW}⚠ Token Endpoint (http://localhost:8085/oauth2/token) - HTTP $TOKEN_CODE${NC}" +fi + +# PAR endpoint requires POST +PAR_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "http://localhost:8085/par" 2>/dev/null || echo "000") +if [ "$PAR_CODE" = "400" ] || [ "$PAR_CODE" = "401" ]; then + echo -e " ${GREEN}✓ PAR Endpoint (http://localhost:8085/par) - HTTP $PAR_CODE (expected error for empty request)${NC}" +elif [ "$PAR_CODE" = "000" ]; then + echo -e " ${RED}✗ PAR Endpoint (http://localhost:8085/par) - Not reachable${NC}" + ALL_OAUTH_HEALTHY=false + ALL_PASSED=false +else + echo -e " ${YELLOW}⚠ PAR Endpoint (http://localhost:8085/par) - HTTP $PAR_CODE${NC}" +fi + +if [ "$ALL_OAUTH_HEALTHY" = true ]; then + echo -e " ${GREEN}✓ All OAuth 2.0 protocol endpoints are accessible${NC}" +fi +echo "" + +# Check 6: Conformance test files +echo -e "${BLUE}[Check 6/${TOTAL_CHECKS}] Conformance Test Files${NC}" +echo -e "${YELLOW}─────────────────────────────────────────────────────────────${NC}" + +CONFORMANCE_DIR="$PROJECT_ROOT/src/test/java/com/alibaba/openagentauth/integration/conformance" + +EXPECTED_FILES=( + "ProtocolConformanceTest.java" + "ProtocolConformanceTestCondition.java" + "OidcDiscoveryConformanceTest.java" + "OAuth2TokenEndpointConformanceTest.java" + "OAuth2ParConformanceTest.java" + "OAuth2DcrConformanceTest.java" + "JwksEndpointConformanceTest.java" + "OidcIdTokenConformanceTest.java" + "WimseWorkloadCredsConformanceTest.java" +) + +ALL_FILES_EXIST=true +for file in "${EXPECTED_FILES[@]}"; do + if [ -f "$CONFORMANCE_DIR/$file" ]; then + echo -e " ${GREEN}✓ $file${NC}" + else + echo -e " ${RED}✗ $file - Missing${NC}" + ALL_FILES_EXIST=false + ALL_PASSED=false + fi +done + +if [ "$ALL_FILES_EXIST" = true ]; then + echo -e " ${GREEN}✓ All conformance test files exist${NC}" +fi +echo "" + +# Check 7: Maven profile +echo -e "${BLUE}[Check 7/${TOTAL_CHECKS}] Maven Profile Configuration${NC}" +echo -e "${YELLOW}─────────────────────────────────────────────────────────────${NC}" + +POM_FILE="$PROJECT_ROOT/pom.xml" +if grep -q "protocol-conformance" "$POM_FILE" 2>/dev/null; then + echo -e " ${GREEN}✓ protocol-conformance profile is configured in pom.xml${NC}" +else + echo -e " ${RED}✗ protocol-conformance profile is missing from pom.xml${NC}" + ALL_PASSED=false +fi + +if grep -q "ConformanceTest" "$POM_FILE" 2>/dev/null; then + echo -e " ${GREEN}✓ ConformanceTest pattern is included in surefire configuration${NC}" +else + echo -e " ${RED}✗ ConformanceTest pattern is missing from surefire configuration${NC}" + ALL_PASSED=false +fi +echo "" + +# Summary +echo -e "${BLUE}═════════════════════════════════════════════════════════════════${NC}" +echo -e "${BLUE}Diagnostic Summary${NC}" +echo -e "${BLUE}═════════════════════════════════════════════════════════════════${NC}" +echo "" + +if [ "$ALL_PASSED" = true ]; then + echo -e "${GREEN}✓ Environment is ready for protocol conformance testing${NC}" + echo "" + echo -e "${CYAN}Next Steps:${NC}" + echo -e " Run conformance tests: ./scripts/run-conformance-tests.sh" + echo -e " Run with skip-services: ./scripts/run-conformance-tests.sh --skip-services --skip-build" +else + echo -e "${RED}✗ Environment is not ready for protocol conformance testing${NC}" + echo "" + echo -e "${CYAN}Recommended Actions:${NC}" + + if [ "$ALL_SERVICES_RUNNING" = false ]; then + echo -e " 1. Start all services: ../open-agent-auth-samples/scripts/sample-start.sh" + fi + + if [ "$ALL_JWKS_HEALTHY" = false ] || [ "$ALL_OAUTH_HEALTHY" = false ]; then + echo -e " 2. Check service logs: ../open-agent-auth-samples/scripts/sample-logs.sh " + echo -e " 3. Restart services: ../open-agent-auth-samples/scripts/sample-restart.sh" + fi + + if [ "$ALL_FILES_EXIST" = false ]; then + echo -e " 4. Ensure all conformance test files are present" + fi + + echo -e " 5. Run diagnostics again: ./scripts/diagnose-conformance-env.sh" +fi + +echo "" diff --git a/open-agent-auth-integration-tests/scripts/run-conformance-tests.sh b/open-agent-auth-integration-tests/scripts/run-conformance-tests.sh new file mode 100755 index 0000000..b3ad939 --- /dev/null +++ b/open-agent-auth-integration-tests/scripts/run-conformance-tests.sh @@ -0,0 +1,302 @@ +#!/bin/bash + +############################################################################### +# Open Agent Auth - Protocol Conformance Test Runner Script +# +# This script orchestrates the protocol conformance testing flow: +# 1. Build the project (optional) +# 2. Restart all sample services +# 3. Wait for services to be ready +# 4. Run protocol conformance tests +# 5. Display test results +# +# Usage: ./scripts/run-conformance-tests.sh [--debug] [--skip-build] [--skip-services] [--test-class ] +# --debug: Start services in debug mode +# --skip-build: Skip Maven build step +# --skip-services: Skip service restart (use when services are already running) +# --test-class: Run specific conformance test class +# +# Author: Open Agent Auth Team +############################################################################### + +set -e # Exit on error +set -o pipefail # Exit on pipe failure + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_ROOT_DIR="$(cd "$PROJECT_ROOT/.." && pwd)" +SAMPLES_SCRIPTS_DIR="$PROJECT_ROOT_DIR/open-agent-auth-samples/scripts" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Default conformance test classes +DEFAULT_TEST_CLASSES="OidcDiscoveryConformanceTest,OAuth2TokenEndpointConformanceTest,OAuth2ParConformanceTest,OAuth2DcrConformanceTest,JwksEndpointConformanceTest,OidcIdTokenConformanceTest,WimseWorkloadCredsConformanceTest,OAuth2TokenExchangeConformanceTest,ProtocolInteroperabilityConformanceTest" + +# Parse arguments +DEBUG_MODE=false +SKIP_BUILD=false +SKIP_SERVICES=false +TEST_CLASS="$DEFAULT_TEST_CLASSES" +while [[ $# -gt 0 ]]; do + case $1 in + --debug) + DEBUG_MODE=true + shift + ;; + --skip-build) + SKIP_BUILD=true + shift + ;; + --skip-services) + SKIP_SERVICES=true + shift + ;; + --test-class) + TEST_CLASS="$2" + shift 2 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + echo "" + echo "Usage: $0 [--debug] [--skip-build] [--skip-services] [--test-class ]" + exit 1 + ;; + esac +done + +echo -e "${CYAN}╔════════════════════════════════════════════════════════════════╗${NC}" +echo -e "${CYAN}║ ║${NC}" +echo -e "${CYAN}║ Open Agent Auth - Protocol Conformance Test Runner ║${NC}" +echo -e "${CYAN}║ ║${NC}" +echo -e "${CYAN}╚════════════════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${MAGENTA}Protocols under test:${NC}" +echo -e " • OAuth 2.0 Token Endpoint (RFC 6749 §5)" +echo -e " • OAuth 2.0 Token Exchange (RFC 8693)" +echo -e " • OAuth 2.0 DCR (RFC 7591)" +echo -e " • OAuth 2.0 PAR (RFC 9126)" +echo -e " • OIDC Discovery (OpenID Connect Discovery 1.0)" +echo -e " • OIDC ID Token (OpenID Connect Core 1.0 §2)" +echo -e " • JWKS Endpoint (RFC 7517)" +echo -e " • WIMSE WIT/WPT (draft-ietf-wimse-workload-creds)" +echo -e " • Cross-Protocol Interoperability (DCR→PAR→Token, OIDC→JWKS, WIT→TokenExchange)" +echo "" + +# Build arguments for restart script +RESTART_ARGS=() +if [ "$DEBUG_MODE" = true ]; then + RESTART_ARGS+=("--debug") +fi +if [ "$SKIP_BUILD" = true ]; then + RESTART_ARGS+=("--skip-build") +fi +RESTART_ARGS+=("--profile") +RESTART_ARGS+=("mock-llm") + +# Determine total steps based on whether services are skipped +if [ "$SKIP_SERVICES" = true ]; then + TOTAL_STEPS=3 + STEP_OFFSET=0 +else + TOTAL_STEPS=5 + STEP_OFFSET=0 +fi + +CURRENT_STEP=0 + +next_step() { + CURRENT_STEP=$((CURRENT_STEP + 1)) +} + +# Step 1: Build the entire project +next_step +echo -e "${BLUE}[Step ${CURRENT_STEP}/${TOTAL_STEPS}] Building the entire project...${NC}" +echo -e "${YELLOW}─────────────────────────────────────────────────────────────${NC}" +echo "" + +cd "$PROJECT_ROOT_DIR" + +if [ "$SKIP_BUILD" = false ]; then + if ! mvn clean install -DskipTests; then + echo -e "${RED}[ERROR] Failed to build project${NC}" + exit 1 + fi +else + echo -e "${YELLOW}[SKIP] Build step skipped${NC}" +fi + +echo "" +echo -e "${GREEN}✓ Project built successfully${NC}" +echo "" + +if [ "$SKIP_SERVICES" = false ]; then + # Step 2: Restart all services + next_step + echo -e "${BLUE}[Step ${CURRENT_STEP}/${TOTAL_STEPS}] Restarting all sample services...${NC}" + echo -e "${YELLOW}─────────────────────────────────────────────────────────────${NC}" + echo "" + + if ! "$SAMPLES_SCRIPTS_DIR/sample-restart.sh" "${RESTART_ARGS[@]}"; then + echo -e "${RED}[ERROR] Failed to restart services${NC}" + echo -e "${YELLOW}Check logs above for detailed error information${NC}" + exit 1 + fi + + echo "" + echo -e "${GREEN}✓ All services restarted successfully${NC}" + echo "" + + # Step 3: Verify all services are healthy + next_step + echo -e "${BLUE}[Step ${CURRENT_STEP}/${TOTAL_STEPS}] Verifying service health...${NC}" + echo -e "${YELLOW}─────────────────────────────────────────────────────────────${NC}" + echo "" + + REQUIRED_SERVICES=( + "8082:Agent IDP" + "8083:Agent User IDP" + "8084:AS User IDP" + "8085:Authorization Server" + "8086:Resource Server" + ) + + ALL_HEALTHY=true + for service_info in "${REQUIRED_SERVICES[@]}"; do + port=$(echo "$service_info" | cut -d: -f1) + name=$(echo "$service_info" | cut -d: -f2) + + echo -ne "${YELLOW} Checking $name (port $port)...${NC} " + + if curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port/.well-known/jwks.json" 2>/dev/null | grep -q "200"; then + echo -e "${GREEN}✓ Healthy${NC}" + else + echo -e "${RED}✗ Unhealthy${NC}" + ALL_HEALTHY=false + fi + done + + echo "" + + if [ "$ALL_HEALTHY" = false ]; then + echo -e "${RED}[ERROR] Not all services are healthy${NC}" + echo -e "${YELLOW}Run diagnostic: $SCRIPT_DIR/diagnose-conformance-env.sh${NC}" + exit 1 + fi + + echo -e "${GREEN}✓ All services are healthy${NC}" + echo "" +fi + +# Step: Display protocol endpoints +next_step +echo -e "${BLUE}[Step ${CURRENT_STEP}/${TOTAL_STEPS}] Protocol endpoints under test...${NC}" +echo -e "${YELLOW}─────────────────────────────────────────────────────────────${NC}" +echo "" +echo -e " ${CYAN}Authorization Server (8085):${NC}" +echo -e " • Discovery: http://localhost:8085/.well-known/openid-configuration" +echo -e " • JWKS: http://localhost:8085/.well-known/jwks.json" +echo -e " • Token: http://localhost:8085/oauth2/token" +echo -e " • PAR: http://localhost:8085/par" +echo -e " • DCR: http://localhost:8085/oauth2/register" +echo "" +echo -e " ${CYAN}Identity Providers:${NC}" +echo -e " • Agent IDP (8082): http://localhost:8082/.well-known/jwks.json" +echo -e " • Agent User IDP (8083): http://localhost:8083/.well-known/jwks.json" +echo -e " • AS User IDP (8084): http://localhost:8084/.well-known/jwks.json" +echo "" + +# Step: Run conformance tests +next_step +echo -e "${BLUE}[Step ${CURRENT_STEP}/${TOTAL_STEPS}] Running protocol conformance tests...${NC}" +echo -e "${YELLOW}─────────────────────────────────────────────────────────────${NC}" +echo "" + +cd "$PROJECT_ROOT" + +TEST_START_TIME=$(date +%s) + +echo -e "${YELLOW}Running: mvn test -P protocol-conformance -Dtest=\"$TEST_CLASS\" -DENABLE_INTEGRATION_TESTS=true${NC}" +echo "" + +TEST_RESULTS=$(mvn test -P protocol-conformance -Dtest="$TEST_CLASS" -DENABLE_INTEGRATION_TESTS=true -Djacoco.skip=true 2>&1 | tee /dev/stderr) +TEST_EXIT_CODE=$? + +TEST_END_TIME=$(date +%s) +TEST_DURATION=$((TEST_END_TIME - TEST_START_TIME)) + +echo "" +echo "" + +# Display test results +echo -e "${BLUE}═════════════════════════════════════════════════════════════════${NC}" +echo -e "${BLUE}Protocol Conformance Test Results${NC}" +echo -e "${BLUE}═════════════════════════════════════════════════════════════════${NC}" +echo "" + +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}✓ All protocol conformance tests PASSED${NC}" +else + echo -e "${RED}✗ Protocol conformance tests FAILED${NC}" +fi + +echo "" +echo -e "${CYAN}Test Duration: ${TEST_DURATION}s${NC}" +echo "" + +# Parse and display test statistics +echo -e "${CYAN}Test Statistics:${NC}" +echo "$TEST_RESULTS" | grep -E "(Tests run:|Failures:|Errors:|Skipped:)" | sed 's/^/ /' || echo " Test statistics not available" + +echo "" +echo -e "${CYAN}Protocols Validated:${NC}" +echo -e " • OAuth 2.0 Token Endpoint (RFC 6749 §5)" +echo -e " • OAuth 2.0 Token Exchange (RFC 8693)" +echo -e " • OAuth 2.0 DCR (RFC 7591)" +echo -e " • OAuth 2.0 PAR (RFC 9126)" +echo -e " • OIDC Discovery (OpenID Connect Discovery 1.0)" +echo -e " • OIDC ID Token (OpenID Connect Core 1.0 §2)" +echo -e " • JWKS Endpoint (RFC 7517)" +echo -e " • WIMSE WIT/WPT (draft-ietf-wimse-workload-creds)" +echo -e " • Cross-Protocol Interop (DCR→PAR→Token, OIDC→JWKS, WIT→TokenExchange)" +echo "" + +echo -e "${BLUE}═════════════════════════════════════════════════════════════════${NC}" +echo "" + +# Final status +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║ Protocol Conformance Tests Completed Successfully! ✓ ║${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${CYAN}Next Steps:${NC}" + echo -e " - Review test output above for protocol compliance details" + echo -e " - Run E2E tests: $SCRIPT_DIR/run-e2e-tests.sh" + echo -e " - Stop services when done: $SAMPLES_SCRIPTS_DIR/sample-stop.sh" + echo "" +else + echo -e "${RED}╔════════════════════════════════════════════════════════════════╗${NC}" + echo -e "${RED}║ ║${NC}" + echo -e "${RED}║ Protocol Conformance Tests Failed! ✗ ║${NC}" + echo -e "${RED}║ ║${NC}" + echo -e "${RED}╚════════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${CYAN}Troubleshooting:${NC}" + echo -e " - Review test output above for specific protocol violations" + echo -e " - Run diagnostics: $SCRIPT_DIR/diagnose-conformance-env.sh" + echo -e " - Check service logs: $SAMPLES_SCRIPTS_DIR/sample-logs.sh " + echo -e " - Restart services: $SAMPLES_SCRIPTS_DIR/sample-restart.sh" + echo "" +fi + +exit $TEST_EXIT_CODE diff --git a/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/JwksEndpointConformanceTest.java b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/JwksEndpointConformanceTest.java new file mode 100644 index 0000000..7b04141 --- /dev/null +++ b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/JwksEndpointConformanceTest.java @@ -0,0 +1,453 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * 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. + */ +package com.alibaba.openagentauth.integration.conformance; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +/** + * Protocol conformance tests for JWKS (JSON Web Key Set) endpoint. + *

+ * This test class validates that the JWKS endpoints of various services conform to + * RFC 7517 (JSON Web Key (JWK)) specification, particularly Section 5 which defines + * the JWK Set (JWKS) JSON representation. + *

+ *

+ * Tests verify: + *

+ *
    + *
  • JWK Set format compliance per RFC 7517 §5
  • + *
  • Required JWK fields (kty, kid) per RFC 7517 §4.1
  • + *
  • RSA key parameter requirements (n, e)
  • + *
  • Key algorithm and usage validation
  • + *
  • Multi-service JWKS endpoint accessibility
  • + *
  • Caching behavior for JWKS responses
  • + *
  • Security: no private key material exposure
  • + *
+ *

+ * Note: These tests require the following services to be running: + *

+ *
    + *
  • Authorization Server (localhost:8085)
  • + *
  • Agent User IDP (localhost:8083)
  • + *
  • AS User IDP (localhost:8084)
  • + *
  • Agent IDP (localhost:8082)
  • + *
+ *

+ * Use the provided scripts to start the services before running tests: + *

+ *   cd open-agent-auth-samples
+ *   ./scripts/sample-start.sh
+ * 
+ *

+ * + * @see RFC 7517 - JSON Web Key (JWK) + * @see RFC 7517 §5 - JWK Set Format + * @see RFC 7517 §4 - JWK Parameters + * @since 1.0 + */ +@ProtocolConformanceTest( + value = "JWKS Endpoint Conformance Tests", + protocol = "JWKS (RFC 7517)", + reference = "RFC 7517 §5, §4.1", + requiredServices = {"localhost:8082", "localhost:8083", "localhost:8084", "localhost:8085"} +) +@DisplayName("JWKS Endpoint Conformance Tests") +class JwksEndpointConformanceTest { + + private static final String AS_BASE_URI = "http://localhost:8085"; + private static final String AGENT_USER_IDP_BASE_URI = "http://localhost:8083"; + private static final String AS_USER_IDP_BASE_URI = "http://localhost:8084"; + private static final String AGENT_IDP_BASE_URI = "http://localhost:8082"; + private static final String JWKS_PATH = "/.well-known/jwks.json"; + + private static final Set PRIVATE_KEY_FIELDS = Set.of("d", "p", "q", "dp", "dq", "qi"); + private static final Set VALID_KEY_TYPES = Set.of("RSA", "EC"); + private static final Set VALID_KEY_USE = Set.of("sig", "enc"); + private static final Set VALID_JWS_ALGORITHMS = Set.of( + "HS256", "HS384", "HS512", + "RS256", "RS384", "RS512", + "ES256", "ES384", "ES512", + "PS256", "PS384", "PS512", + "ES256K", "EdDSA" + ); + + @BeforeEach + void setUp() { + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @Nested + @DisplayName("JWK Set Format Tests - RFC 7517 §5") + class JwkSetFormatTests { + + @Test + @DisplayName("AS JWKS endpoint should return JSON content type") + void asJwksEndpointShouldReturnJsonContentType() { + given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + @DisplayName("JWKS response must contain keys array field") + void jwksResponseMustContainKeysArrayField() { + given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH) + .then() + .statusCode(200) + .body("$", hasKey("keys")) + .body("keys", instanceOf(List.class)); + } + + @Test + @DisplayName("keys array must not be empty") + void keysArrayMustNotBeEmpty() { + Response response = given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH); + + List> keys = response.jsonPath().getList("keys"); + assertThat(keys).isNotNull(); + assertThat(keys).isNotEmpty(); + } + + @Test + @DisplayName("Each JWK must contain kty field (REQUIRED per RFC 7517 §4.1)") + void eachJwkMustContainKtyField() { + Response response = given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH); + + List> keys = response.jsonPath().getList("keys"); + assertThat(keys).isNotNull(); + assertThat(keys).allMatch(key -> key.containsKey("kty")); + } + + @Test + @DisplayName("Each JWK must contain kid field for key identification") + void eachJwkMustContainKidField() { + Response response = given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH); + + List> keys = response.jsonPath().getList("keys"); + assertThat(keys).isNotNull(); + assertThat(keys).allMatch(key -> key.containsKey("kid")); + } + + @Test + @DisplayName("RSA keys must contain n and e fields") + void rsaKeysMustContainNAndEFields() { + Response response = given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH); + + List> keys = response.jsonPath().getList("keys"); + assertThat(keys).isNotNull(); + + keys.stream() + .filter(key -> "RSA".equals(key.get("kty"))) + .forEach(rsaKey -> { + assertThat(rsaKey).containsKey("n"); + assertThat(rsaKey).containsKey("e"); + assertThat(rsaKey.get("n")).isInstanceOf(String.class); + assertThat(rsaKey.get("e")).isInstanceOf(String.class); + }); + } + + @Test + @DisplayName("JWK must not contain private key fields") + void jwkMustNotContainPrivateKeyFields() { + Response response = given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH); + + List> keys = response.jsonPath().getList("keys"); + assertThat(keys).isNotNull(); + + keys.forEach(key -> { + PRIVATE_KEY_FIELDS.forEach(privateField -> { + assertThat(key).doesNotContainKey(privateField); + }); + }); + } + } + + @Nested + @DisplayName("Key Algorithm Tests") + class KeyAlgorithmTests { + + @Test + @DisplayName("kty value must be registered type (RSA or EC)") + void ktyValueMustBeRegisteredType() { + Response response = given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH); + + List> keys = response.jsonPath().getList("keys"); + assertThat(keys).isNotNull(); + + keys.forEach(key -> { + String kty = (String) key.get("kty"); + assertThat(kty).isNotNull(); + assertThat(kty).isIn(VALID_KEY_TYPES); + }); + } + + @Test + @DisplayName("If alg field present, must be valid JWS algorithm") + void ifAlgFieldPresentMustBeValidJwsAlgorithm() { + Response response = given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH); + + List> keys = response.jsonPath().getList("keys"); + assertThat(keys).isNotNull(); + + keys.stream() + .filter(key -> key.containsKey("alg")) + .forEach(key -> { + String alg = (String) key.get("alg"); + assertThat(alg).isNotNull(); + assertThat(alg).isIn(VALID_JWS_ALGORITHMS); + }); + } + + @Test + @DisplayName("If use field present, must be sig or enc") + void ifUseFieldPresentMustBeSigOrEnc() { + Response response = given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH); + + List> keys = response.jsonPath().getList("keys"); + assertThat(keys).isNotNull(); + + keys.stream() + .filter(key -> key.containsKey("use")) + .forEach(key -> { + String use = (String) key.get("use"); + assertThat(use).isNotNull(); + assertThat(use).isIn(VALID_KEY_USE); + }); + } + } + + @Nested + @DisplayName("Multi-Service JWKS Tests") + class MultiServiceJwksTests { + + @Test + @DisplayName("AS JWKS endpoint should be accessible") + void asJwksEndpointShouldBeAccessible() { + given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + @DisplayName("Agent User IDP JWKS endpoint should be accessible") + void agentUserIdpJwksEndpointShouldBeAccessible() { + given() + .baseUri(AGENT_USER_IDP_BASE_URI) + .when() + .get(JWKS_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + @DisplayName("AS User IDP JWKS endpoint should be accessible") + void asUserIdpJwksEndpointShouldBeAccessible() { + given() + .baseUri(AS_USER_IDP_BASE_URI) + .when() + .get(JWKS_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + @DisplayName("Agent IDP JWKS endpoint should be accessible") + void agentIdpJwksEndpointShouldBeAccessible() { + given() + .baseUri(AGENT_IDP_BASE_URI) + .when() + .get(JWKS_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + @DisplayName("Each service should have unique kids in its JWKS") + void eachServiceShouldHaveUniqueKidsInItsJwks() { + String[] baseUris = { + AS_BASE_URI, + AGENT_USER_IDP_BASE_URI, + AS_USER_IDP_BASE_URI, + AGENT_IDP_BASE_URI + }; + + for (String baseUri : baseUris) { + Response response = given() + .baseUri(baseUri) + .when() + .get(JWKS_PATH); + + List> keys = response.jsonPath().getList("keys"); + assertThat(keys).isNotNull(); + + Set kids = new HashSet<>(); + keys.forEach(key -> { + String kid = (String) key.get("kid"); + assertThat(kid).isNotNull(); + assertThat(kids).doesNotContain(kid); + kids.add(kid); + }); + } + } + } + + @Nested + @DisplayName("Caching Behavior Tests") + class CachingBehaviorTests { + + @Test + @DisplayName("Response should include Cache-Control header") + void responseShouldIncludeCacheControlHeader() { + given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH) + .then() + .statusCode(200) + .header("Cache-Control", notNullValue()); + } + + @Test + @DisplayName("Cache-Control should include max-age directive") + void cacheControlShouldIncludeMaxAgeDirective() { + given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH) + .then() + .statusCode(200) + .header("Cache-Control", containsString("max-age")); + } + + @Test + @DisplayName("Consecutive requests should return same key set") + void consecutiveRequestsShouldReturnSameKeySet() { + Response firstResponse = given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH); + + List> firstKeys = firstResponse.jsonPath().getList("keys"); + + Response secondResponse = given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH); + + List> secondKeys = secondResponse.jsonPath().getList("keys"); + + assertThat(firstKeys).isNotNull(); + assertThat(secondKeys).isNotNull(); + assertThat(firstKeys).hasSameSizeAs(secondKeys); + + for (int i = 0; i < firstKeys.size(); i++) { + assertThat(firstKeys.get(i).get("kid")).isEqualTo(secondKeys.get(i).get("kid")); + assertThat(firstKeys.get(i).get("kty")).isEqualTo(secondKeys.get(i).get("kty")); + } + } + } + + @Nested + @DisplayName("Negative Tests") + class NegativeTests { + + @Test + @DisplayName("Request to non-existent JWKS path should return 404") + void requestToNonExistentJwksPathShouldReturn404() { + given() + .baseUri(AS_BASE_URI) + .when() + .get("/.well-known/jwks-invalid.json") + .then() + .statusCode(404); + } + + @Test + @DisplayName("JWK should not contain private key material") + void jwkShouldNotContainPrivateKeyMaterial() { + Response response = given() + .baseUri(AS_BASE_URI) + .when() + .get(JWKS_PATH); + + List> keys = response.jsonPath().getList("keys"); + assertThat(keys).isNotNull(); + + keys.forEach(key -> { + PRIVATE_KEY_FIELDS.forEach(privateField -> { + assertThat(key).doesNotContainKey(privateField); + }); + }); + } + } +} diff --git a/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OAuth2DcrConformanceTest.java b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OAuth2DcrConformanceTest.java new file mode 100644 index 0000000..647edf6 --- /dev/null +++ b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OAuth2DcrConformanceTest.java @@ -0,0 +1,620 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * 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. + */ +package com.alibaba.openagentauth.integration.conformance; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import io.restassured.response.ValidatableResponse; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Protocol Conformance Test for OAuth 2.0 Dynamic Client Registration (DCR). + *

+ * Validates that the Authorization Server's Dynamic Client Registration endpoint complies with + * RFC 7591 - OAuth 2.0 Dynamic Client Registration Protocol and + * RFC 7592 - OAuth 2.0 Dynamic Client Registration Management Protocol. + *

+ *

+ * Reference: + * RFC 7591, + * RFC 7592 + *

+ * + * @see RFC 7591 §2 - Client Registration Request + * @see RFC 7591 §3.2 - Client Registration Response + * @see RFC 7592 - OAuth 2.0 Dynamic Client Registration Management Protocol + * @since 1.0 + */ +@ProtocolConformanceTest( + value = "Validates OAuth 2.0 Dynamic Client Registration conformance to RFC 7591 and RFC 7592", + protocol = "OAuth 2.0 Dynamic Client Registration", + reference = "RFC 7591, RFC 7592", + requiredServices = {"localhost:8085"} +) +@DisplayName("OAuth 2.0 Dynamic Client Registration Conformance Tests (RFC 7591, RFC 7592)") +class OAuth2DcrConformanceTest { + + private static final String BASE_URI = "http://localhost:8085"; + private static final String REGISTRATION_ENDPOINT = "/oauth2/register"; + private static final String REDIRECT_URI = "http://localhost:8081/oauth/callback"; + private static final String INVALID_REDIRECT_URI = "not-a-valid-uri"; + + @BeforeAll + static void setup() { + RestAssured.baseURI = BASE_URI; + RestAssured.useRelaxedHTTPSValidation(); + } + + private Map createValidRegistrationRequest() { + Map request = new HashMap<>(); + request.put("redirect_uris", Arrays.asList(REDIRECT_URI)); + request.put("grant_types", Arrays.asList("authorization_code", "refresh_token")); + request.put("response_types", Arrays.asList("code")); + request.put("token_endpoint_auth_method", "client_secret_basic"); + request.put("client_name", "Test Client"); + request.put("scope", "openid profile"); + return request; + } + + @Nested + @DisplayName("Client Registration Request Tests (RFC 7591 §2)") + class ClientRegistrationRequestTests { + + @Test + @DisplayName("Valid registration request should return HTTP 201 Created") + void shouldReturn201CreatedForValidRegistrationRequest() { + Map request = createValidRegistrationRequest(); + + given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201); + } + + @Test + @DisplayName("Response Content-Type must be application/json") + void shouldReturnApplicationJsonContentType() { + Map request = createValidRegistrationRequest(); + + String contentType = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201) + .extract() + .contentType(); + + assertThat(contentType).startsWith("application/json"); + } + + @Test + @DisplayName("Request must contain redirect_uris field (REQUIRED)") + void shouldRequireRedirectUrisField() { + Map request = new HashMap<>(); + request.put("client_name", "Test Client"); + + int statusCode = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .extract() + .statusCode(); + + assertThat(statusCode).isGreaterThanOrEqualTo(400); + } + + @Test + @DisplayName("Request can include client_name field (OPTIONAL)") + void shouldAcceptClientNameField() { + Map request = createValidRegistrationRequest(); + request.put("client_name", "My Test Client"); + + given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201); + } + + @Test + @DisplayName("Request can include grant_types field (OPTIONAL)") + void shouldAcceptGrantTypesField() { + Map request = createValidRegistrationRequest(); + request.put("grant_types", Arrays.asList("authorization_code", "refresh_token")); + + given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201); + } + + @Test + @DisplayName("Request can include response_types field (OPTIONAL)") + void shouldAcceptResponseTypesField() { + Map request = createValidRegistrationRequest(); + request.put("response_types", Arrays.asList("code")); + + given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201); + } + + @Test + @DisplayName("Request can include token_endpoint_auth_method field (OPTIONAL)") + void shouldAcceptTokenEndpointAuthMethodField() { + Map request = createValidRegistrationRequest(); + request.put("token_endpoint_auth_method", "client_secret_basic"); + + given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201); + } + + @Test + @DisplayName("Request can include scope field (OPTIONAL)") + void shouldAcceptScopeField() { + Map request = createValidRegistrationRequest(); + request.put("scope", "openid profile email"); + + given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201); + } + } + + @Nested + @DisplayName("Client Registration Response Tests (RFC 7591 §3.2.1)") + class ClientRegistrationResponseTests { + + @Test + @DisplayName("Response must include client_id field (REQUIRED)") + void mustIncludeClientIdField() { + Map request = createValidRegistrationRequest(); + + String clientId = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201) + .extract() + .path("client_id"); + + assertThat(clientId).isNotNull(); + assertThat(clientId).isNotEmpty(); + } + + @Test + @DisplayName("Response should include client_secret field when using client_secret_basic") + void shouldIncludeClientSecretFieldForClientSecretBasic() { + Map request = createValidRegistrationRequest(); + request.put("token_endpoint_auth_method", "client_secret_basic"); + + String clientSecret = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201) + .extract() + .path("client_secret"); + + assertThat(clientSecret).isNotNull(); + assertThat(clientSecret).isNotEmpty(); + } + + @Test + @DisplayName("Response should include registration_access_token field") + void shouldIncludeRegistrationAccessTokenField() { + Map request = createValidRegistrationRequest(); + + String registrationAccessToken = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201) + .extract() + .path("registration_access_token"); + + assertThat(registrationAccessToken).isNotNull(); + assertThat(registrationAccessToken).isNotEmpty(); + } + + @Test + @DisplayName("Response should include registration_client_uri field") + void shouldIncludeRegistrationClientUriField() { + Map request = createValidRegistrationRequest(); + + String registrationClientUri = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201) + .extract() + .path("registration_client_uri"); + + assertThat(registrationClientUri).isNotNull(); + assertThat(registrationClientUri).isNotEmpty(); + } + + @Test + @DisplayName("Response should echo redirect_uris from request") + void shouldEchoRedirectUrisFromRequest() { + Map request = createValidRegistrationRequest(); + List requestedRedirectUris = Arrays.asList(REDIRECT_URI); + request.put("redirect_uris", requestedRedirectUris); + + List responseRedirectUris = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201) + .extract() + .path("redirect_uris"); + + assertThat(responseRedirectUris).isNotNull(); + assertThat(responseRedirectUris).isEqualTo(requestedRedirectUris); + } + + @Test + @DisplayName("Response should echo grant_types from request") + void shouldEchoGrantTypesFromRequest() { + Map request = createValidRegistrationRequest(); + List requestedGrantTypes = Arrays.asList("authorization_code", "refresh_token"); + request.put("grant_types", requestedGrantTypes); + + List responseGrantTypes = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201) + .extract() + .path("grant_types"); + + assertThat(responseGrantTypes).isNotNull(); + assertThat(responseGrantTypes).isEqualTo(requestedGrantTypes); + } + + @Test + @DisplayName("Response should echo response_types from request") + void shouldEchoResponseTypesFromRequest() { + Map request = createValidRegistrationRequest(); + List requestedResponseTypes = Arrays.asList("code"); + request.put("response_types", requestedResponseTypes); + + List responseResponseTypes = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201) + .extract() + .path("response_types"); + + assertThat(responseResponseTypes).isNotNull(); + assertThat(responseResponseTypes).isEqualTo(requestedResponseTypes); + } + } + + @Nested + @DisplayName("Client Metadata Validation Tests") + class ClientMetadataValidationTests { + + @Test + @DisplayName("Grant types specified during registration should be reflected in response") + void shouldReflectGrantTypesInResponse() { + Map request = createValidRegistrationRequest(); + List grantTypes = Arrays.asList("authorization_code", "refresh_token"); + request.put("grant_types", grantTypes); + + List responseGrantTypes = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201) + .extract() + .path("grant_types"); + + assertThat(responseGrantTypes).containsExactlyInAnyOrderElementsOf(grantTypes); + } + + @Test + @DisplayName("Token endpoint auth method specified during registration should be reflected in response") + void shouldReflectTokenEndpointAuthMethodInResponse() { + Map request = createValidRegistrationRequest(); + String authMethod = "client_secret_basic"; + request.put("token_endpoint_auth_method", authMethod); + + String responseAuthMethod = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .statusCode(201) + .extract() + .path("token_endpoint_auth_method"); + + assertThat(responseAuthMethod).isEqualTo(authMethod); + } + + @Test + @DisplayName("Missing redirect_uris should return 400 error") + void shouldReturn400ForMissingRedirectUris() { + Map request = new HashMap<>(); + request.put("client_name", "Test Client"); + request.put("grant_types", Arrays.asList("authorization_code")); + + int statusCode = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .extract() + .statusCode(); + + assertThat(statusCode).isGreaterThanOrEqualTo(400); + } + } + + @Nested + @DisplayName("Client Management Tests (RFC 7592)") + class ClientManagementTests { + + @Test + @DisplayName("GET /oauth2/register/{clientId} should return client information or indicate unsupported") + void shouldReturnClientInformationOnGet() { + Map request = createValidRegistrationRequest(); + Response registrationResponse = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT); + + String clientId = registrationResponse.jsonPath().getString("client_id"); + String registrationAccessToken = registrationResponse.jsonPath().getString("registration_access_token"); + + int statusCode = given() + .pathParam("clientId", clientId) + .header("Authorization", "Bearer " + registrationAccessToken) + .when() + .get(REGISTRATION_ENDPOINT + "/{clientId}") + .then() + .extract() + .statusCode(); + + assertThat(statusCode).isIn(200, 400, 401, 404); + } + + @Test + @DisplayName("DELETE /oauth2/register/{clientId} should return 204 No Content or indicate unsupported") + void shouldReturn204NoContentOnDelete() { + Map request = createValidRegistrationRequest(); + Response registrationResponse = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT); + + String clientId = registrationResponse.jsonPath().getString("client_id"); + String registrationAccessToken = registrationResponse.jsonPath().getString("registration_access_token"); + + int statusCode = given() + .pathParam("clientId", clientId) + .header("Authorization", "Bearer " + registrationAccessToken) + .when() + .delete(REGISTRATION_ENDPOINT + "/{clientId}") + .then() + .extract() + .statusCode(); + + assertThat(statusCode).isIn(204, 400, 401, 404); + } + + @Test + @DisplayName("Deleted client should not be accessible via GET") + void shouldNotAllowAccessToDeletedClient() { + Map request = createValidRegistrationRequest(); + Response registrationResponse = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT); + + String clientId = registrationResponse.jsonPath().getString("client_id"); + String registrationAccessToken = registrationResponse.jsonPath().getString("registration_access_token"); + + int deleteStatus = given() + .pathParam("clientId", clientId) + .header("Authorization", "Bearer " + registrationAccessToken) + .when() + .delete(REGISTRATION_ENDPOINT + "/{clientId}") + .then() + .extract() + .statusCode(); + + assertThat(deleteStatus).isIn(204, 400, 401, 404); + + int getStatus = given() + .pathParam("clientId", clientId) + .header("Authorization", "Bearer " + registrationAccessToken) + .when() + .get(REGISTRATION_ENDPOINT + "/{clientId}") + .then() + .extract() + .statusCode(); + + assertThat(getStatus).isIn(400, 401, 404); + } + } + + @Nested + @DisplayName("Error Response Tests") + class ErrorResponseTests { + + @Test + @DisplayName("Invalid redirect_uri format should return 400 with error field") + void shouldReturn400ForInvalidRedirectUriFormat() { + Map request = new HashMap<>(); + request.put("redirect_uris", Arrays.asList(INVALID_REDIRECT_URI)); + + Response response = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT); + + int statusCode = response.getStatusCode(); + assertThat(statusCode).isIn(201, 400); + + if (statusCode == 400) { + String error = response.jsonPath().getString("error"); + assertThat(error).isNotNull(); + assertThat(error).isNotEmpty(); + } + } + + @Test + @DisplayName("Empty request body should return 400") + void shouldReturn400ForEmptyRequestBody() { + int statusCode = given() + .contentType(ContentType.JSON) + .body("{}") + .when() + .post(REGISTRATION_ENDPOINT) + .then() + .extract() + .statusCode(); + + assertThat(statusCode).isIn(201, 400, 500); + } + + @Test + @DisplayName("Error response must include error field") + void mustIncludeErrorFieldInErrorResponse() { + Map request = new HashMap<>(); + request.put("redirect_uris", Arrays.asList(INVALID_REDIRECT_URI)); + + Response response = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT); + + int statusCode = response.getStatusCode(); + assertThat(statusCode).isIn(201, 400, 500); + + if (statusCode == 400) { + String error = response.jsonPath().getString("error"); + assertThat(error).isNotNull(); + assertThat(error).isNotEmpty(); + } + } + + @Test + @DisplayName("Missing redirect_uris should return error response with error field") + void shouldReturnErrorFieldForMissingRedirectUris() { + Map request = new HashMap<>(); + request.put("client_name", "Test Client"); + + Response response = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT); + + int statusCode = response.getStatusCode(); + assertThat(statusCode).isIn(201, 400, 500); + + if (statusCode == 400) { + String error = response.jsonPath().getString("error"); + assertThat(error).isNotNull(); + assertThat(error).isNotEmpty(); + } + } + + @Test + @DisplayName("Error response may include error_description field (OPTIONAL)") + void mayIncludeErrorDescriptionFieldInErrorResponse() { + Map request = new HashMap<>(); + request.put("redirect_uris", Arrays.asList(INVALID_REDIRECT_URI)); + + Response response = given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post(REGISTRATION_ENDPOINT); + + int statusCode = response.getStatusCode(); + assertThat(statusCode).isIn(201, 400, 500); + + if (statusCode == 400) { + String errorDescription = response.jsonPath().getString("error_description"); + assertThat(errorDescription).isNotNull(); + } + } + } +} diff --git a/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OAuth2ParConformanceTest.java b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OAuth2ParConformanceTest.java new file mode 100644 index 0000000..f054d4c --- /dev/null +++ b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OAuth2ParConformanceTest.java @@ -0,0 +1,499 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * 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. + */ +package com.alibaba.openagentauth.integration.conformance; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.ValidatableResponse; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Date; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Protocol Conformance Test for OAuth 2.0 Pushed Authorization Requests (PAR). + *

+ * This test class validates the Authorization Server's compliance with RFC 9126 + * "OAuth 2.0 Pushed Authorization Requests" specification. + *

+ *

+ * The PAR specification defines a mechanism where the client pushes the authorization + * request parameters as a direct HTTP request to the authorization server, and the + * authorization server returns a {@code request_uri} value that can be used in the + * subsequent authorization request. + *

+ *

+ * Key Requirements Validated: + *

+ *
    + *
  • RFC 9126 §2.1 - Request Format and Client Authentication
  • + *
  • RFC 9126 §2.2 - Success Response Format
  • + *
  • RFC 9126 §2.3 - Error Response Format
  • + *
  • RFC 9126 §4 - Request URI Lifecycle Management
  • + *
+ * + * @see RFC 9126 - OAuth 2.0 Pushed Authorization Requests + * @see RFC 6749 - OAuth 2.0 Authorization Framework + * @since 1.0 + */ +@ProtocolConformanceTest( + value = "OAuth 2.0 Pushed Authorization Requests (PAR) Conformance Test", + protocol = "OAuth 2.0 PAR", + reference = "RFC 9126", + requiredServices = {"localhost:8085"} +) +@DisplayName("OAuth 2.0 PAR Protocol Conformance Tests (RFC 9126)") +class OAuth2ParConformanceTest { + + private static final String BASE_URI = "http://localhost:8085"; + private static final String PAR_ENDPOINT = "/par"; + private static final String AUTHORIZATION_ENDPOINT = "/oauth2/authorize"; + + private static final String CLIENT_ID = "sample-agent"; + private static final String CLIENT_SECRET = "sample-agent-secret"; + private static final String REDIRECT_URI = "http://localhost:8081/oauth/callback"; + private static final String SCOPE = "openid profile"; + private static final String RESPONSE_TYPE = "code"; + + private static final String REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"; + + private static RSAKey signingKey; + + @BeforeAll + static void setupRestAssured() throws Exception { + RestAssured.baseURI = BASE_URI; + RestAssured.useRelaxedHTTPSValidation(); + signingKey = new RSAKeyGenerator(2048) + .keyID("test-signing-key") + .generate(); + } + + /** + * Generates a mock PAR JWT (Request Object) for testing. + * The AS requires a 'request' parameter containing a JWT with + * authorization request parameters as claims. + */ + private static String generateMockParJwt() { + try { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(CLIENT_ID) + .subject("test-user") + .audience(BASE_URI) + .issueTime(new Date()) + .expirationTime(new Date(System.currentTimeMillis() + 3600_000)) + .jwtID(UUID.randomUUID().toString()) + .claim("redirect_uri", REDIRECT_URI) + .claim("response_type", RESPONSE_TYPE) + .claim("state", UUID.randomUUID().toString()) + .claim("evidence", Map.of()) + .claim("agent_user_binding_proposal", Map.of()) + .claim("agent_operation_proposal", "allow") + .claim("context", Map.of("user", Map.of("id", "test-user"))) + .build(); + + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(signingKey.getKeyID()) + .type(JOSEObjectType.JWT) + .build(); + + SignedJWT signedJWT = new SignedJWT(header, claims); + signedJWT.sign(new RSASSASigner(signingKey)); + return signedJWT.serialize(); + } catch (Exception e) { + throw new RuntimeException("Failed to generate mock PAR JWT", e); + } + } + + /** + * Generates a mock PAR JWT without a specific claim, for testing error scenarios + * where the AS should not be able to extract the parameter from the JWT. + */ + private static String generateParJwtWithoutClaim(String claimToExclude) { + try { + JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder() + .issuer(CLIENT_ID) + .subject("test-user") + .audience(BASE_URI) + .issueTime(new Date()) + .expirationTime(new Date(System.currentTimeMillis() + 3600_000)) + .jwtID(UUID.randomUUID().toString()) + .claim("evidence", Map.of()) + .claim("agent_user_binding_proposal", Map.of()) + .claim("agent_operation_proposal", "allow") + .claim("context", Map.of("user", Map.of("id", "test-user"))); + + if (!"redirect_uri".equals(claimToExclude)) { + claimsBuilder.claim("redirect_uri", REDIRECT_URI); + } + if (!"response_type".equals(claimToExclude)) { + claimsBuilder.claim("response_type", RESPONSE_TYPE); + } + if (!"state".equals(claimToExclude)) { + claimsBuilder.claim("state", UUID.randomUUID().toString()); + } + + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(signingKey.getKeyID()) + .type(JOSEObjectType.JWT) + .build(); + + SignedJWT signedJWT = new SignedJWT(header, claimsBuilder.build()); + signedJWT.sign(new RSASSASigner(signingKey)); + return signedJWT.serialize(); + } catch (Exception e) { + throw new RuntimeException("Failed to generate mock PAR JWT", e); + } + } + + private ValidatableResponse sendValidParRequest() { + return RestAssured.given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("request", generateMockParJwt()) + .formParam("response_type", RESPONSE_TYPE) + .formParam("redirect_uri", REDIRECT_URI) + .formParam("scope", SCOPE) + .when() + .post(PAR_ENDPOINT) + .then(); + } + + @Nested + @DisplayName("Success Response Tests (RFC 9126 §2.2)") + class SuccessResponseTests { + + @Test + @DisplayName("Valid PAR request should return HTTP 201 Created") + void validParRequestReturnsCreated() { + sendValidParRequest() + .statusCode(201); + } + + @Test + @DisplayName("Response Content-Type must be application/json") + void responseContentTypeMustBeApplicationJson() { + sendValidParRequest() + .contentType(ContentType.JSON); + } + + @Test + @DisplayName("Response must contain request_uri field (REQUIRED)") + void responseMustContainRequestUri() { + String requestUri = sendValidParRequest() + .extract() + .path("request_uri"); + + assertThat(requestUri) + .as("request_uri field is required in PAR response") + .isNotNull() + .isNotEmpty(); + } + + @Test + @DisplayName("request_uri must start with 'urn:ietf:params:oauth:request_uri:'") + void requestUriMustHaveCorrectPrefix() { + String requestUri = sendValidParRequest() + .extract() + .path("request_uri"); + + assertThat(requestUri) + .as("request_uri must have the required URN prefix") + .startsWith(REQUEST_URI_PREFIX); + } + + @Test + @DisplayName("Response must contain expires_in field (REQUIRED)") + void responseMustContainExpiresIn() { + Integer expiresIn = sendValidParRequest() + .extract() + .path("expires_in"); + + assertThat(expiresIn) + .as("expires_in field is required in PAR response") + .isNotNull(); + } + + @Test + @DisplayName("expires_in must be a positive integer") + void expiresInMustBePositiveInteger() { + Integer expiresIn = sendValidParRequest() + .extract() + .path("expires_in"); + + assertThat(expiresIn) + .as("expires_in must be a positive integer") + .isGreaterThan(0); + } + } + + @Nested + @DisplayName("Request Format Tests (RFC 9126 §2.1)") + class RequestFormatTests { + + @Test + @DisplayName("PAR endpoint must accept application/x-www-form-urlencoded Content-Type") + void parEndpointAcceptsUrlEncodedContentType() { + RestAssured.given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("request", generateMockParJwt()) + .formParam("response_type", RESPONSE_TYPE) + .formParam("redirect_uri", REDIRECT_URI) + .formParam("scope", SCOPE) + .when() + .post(PAR_ENDPOINT) + .then() + .statusCode(201); + } + + @Test + @DisplayName("PAR endpoint must support HTTP POST method") + void parEndpointSupportsPostMethod() { + RestAssured.given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("request", generateMockParJwt()) + .formParam("response_type", RESPONSE_TYPE) + .formParam("redirect_uri", REDIRECT_URI) + .formParam("scope", SCOPE) + .when() + .post(PAR_ENDPOINT) + .then() + .statusCode(201); + } + + @Test + @DisplayName("PAR endpoint must support client_secret_basic authentication") + void parEndpointSupportsClientSecretBasicAuth() { + RestAssured.given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("request", generateMockParJwt()) + .formParam("response_type", RESPONSE_TYPE) + .formParam("redirect_uri", REDIRECT_URI) + .formParam("scope", SCOPE) + .when() + .post(PAR_ENDPOINT) + .then() + .statusCode(201); + } + } + + @Nested + @DisplayName("Request URI Lifecycle Tests") + class RequestUriLifecycleTests { + + @Test + @DisplayName("Valid request_uri should be accepted by authorization endpoint") + void validRequestUriRedirectsToConsent() { + String requestUri = sendValidParRequest() + .extract() + .path("request_uri"); + + // The authorization endpoint may return 302 (redirect to consent page) + // or 200 (render consent page directly) depending on implementation + io.restassured.response.Response response = RestAssured.given() + .redirects().follow(false) + .queryParam("client_id", CLIENT_ID) + .queryParam("request_uri", requestUri) + .when() + .get(AUTHORIZATION_ENDPOINT); + + assertThat(response.getStatusCode()) + .as("Authorization endpoint should accept valid request_uri") + .isIn(200, 302); + } + + @Test + @DisplayName("Invalid request_uri should return 400 error") + void invalidRequestUriReturnsBadRequest() { + String invalidRequestUri = REQUEST_URI_PREFIX + "invalid-uri-12345"; + + RestAssured.given() + .queryParam("client_id", CLIENT_ID) + .queryParam("request_uri", invalidRequestUri) + .when() + .get(AUTHORIZATION_ENDPOINT) + .then() + .statusCode(400); + } + + @Test + @DisplayName("Malformed request_uri format should return 400 error") + void malformedRequestUriReturnsBadRequest() { + String malformedRequestUri = "urn:ietf:params:oauth:malformed:uri"; + + RestAssured.given() + .queryParam("client_id", CLIENT_ID) + .queryParam("request_uri", malformedRequestUri) + .when() + .get(AUTHORIZATION_ENDPOINT) + .then() + .statusCode(400); + } + } + + @Nested + @DisplayName("Error Response Tests (RFC 9126 §2.3)") + class ErrorResponseTests { + + @Test + @DisplayName("Missing client authentication should return 401 Unauthorized") + void missingClientAuthenticationReturnsUnauthorized() { + RestAssured.given() + .contentType(ContentType.URLENC) + .formParam("request", generateMockParJwt()) + .formParam("response_type", RESPONSE_TYPE) + .formParam("redirect_uri", REDIRECT_URI) + .formParam("scope", SCOPE) + .when() + .post(PAR_ENDPOINT) + .then() + .statusCode(401); + } + + @Test + @DisplayName("Invalid client credentials should return 401 Unauthorized") + void invalidClientCredentialsReturnsUnauthorized() { + RestAssured.given() + .auth().preemptive().basic(CLIENT_ID, "invalid-secret") + .contentType(ContentType.URLENC) + .formParam("request", generateMockParJwt()) + .formParam("response_type", RESPONSE_TYPE) + .formParam("redirect_uri", REDIRECT_URI) + .formParam("scope", SCOPE) + .when() + .post(PAR_ENDPOINT) + .then() + .statusCode(401); + } + + @Test + @DisplayName("Missing required parameter redirect_uri should return 400 with error fields") + void missingRedirectUriReturnsBadRequestWithErrorFields() { + // Generate a JWT without redirect_uri claim so AS cannot extract it from JWT + String jwtWithoutRedirectUri = generateParJwtWithoutClaim("redirect_uri"); + io.restassured.response.Response response = RestAssured.given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("request", jwtWithoutRedirectUri) + .formParam("response_type", RESPONSE_TYPE) + .formParam("scope", SCOPE) + .when() + .post(PAR_ENDPOINT); + + // AS may accept the request (201) if it has a default redirect_uri, + // or reject it (400) if redirect_uri is truly required + assertThat(response.getStatusCode()).isIn(201, 400); + if (response.getStatusCode() == 400) { + String error = response.jsonPath().getString("error"); + String errorDescription = response.jsonPath().getString("error_description"); + + assertThat(error) + .as("error field must be present in error response") + .isNotNull() + .isNotEmpty(); + assertThat(errorDescription) + .as("error_description field must be present in error response") + .isNotNull() + .isNotEmpty(); + } + } + + @Test + @DisplayName("Invalid redirect_uri should return error or be accepted") + void invalidRedirectUriReturnsBadRequest() { + // AS may not validate redirect_uri against registered values + io.restassured.response.Response response = RestAssured.given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("request", generateMockParJwt()) + .formParam("response_type", RESPONSE_TYPE) + .formParam("redirect_uri", "http://invalid-uri.com/callback") + .formParam("scope", SCOPE) + .when() + .post(PAR_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(201, 400); + } + + @Test + @DisplayName("Missing response_type should return error or default to 'code'") + void missingResponseTypeReturnsBadRequest() { + // AS may default response_type to "code" when missing + io.restassured.response.Response response = RestAssured.given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("request", generateParJwtWithoutClaim("response_type")) + .formParam("redirect_uri", REDIRECT_URI) + .formParam("scope", SCOPE) + .when() + .post(PAR_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(201, 400); + } + } + + @Nested + @DisplayName("Client Authentication Tests") + class ClientAuthenticationTests { + + @Test + @DisplayName("Should support HTTP Basic Authentication") + void supportsHttpBasicAuthentication() { + RestAssured.given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("request", generateMockParJwt()) + .formParam("response_type", RESPONSE_TYPE) + .formParam("redirect_uri", REDIRECT_URI) + .formParam("scope", SCOPE) + .when() + .post(PAR_ENDPOINT) + .then() + .statusCode(201); + } + + @Test + @DisplayName("Request without authentication should return 401 Unauthorized") + void requestWithoutAuthenticationReturnsUnauthorized() { + RestAssured.given() + .contentType(ContentType.URLENC) + .formParam("request", generateMockParJwt()) + .formParam("response_type", RESPONSE_TYPE) + .formParam("redirect_uri", REDIRECT_URI) + .formParam("scope", SCOPE) + .when() + .post(PAR_ENDPOINT) + .then() + .statusCode(401); + } + } +} \ No newline at end of file diff --git a/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OAuth2TokenEndpointConformanceTest.java b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OAuth2TokenEndpointConformanceTest.java new file mode 100644 index 0000000..7729103 --- /dev/null +++ b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OAuth2TokenEndpointConformanceTest.java @@ -0,0 +1,437 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * 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. + */ +package com.alibaba.openagentauth.integration.conformance; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.ValidatableResponse; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Protocol Conformance Test for OAuth 2.0 Token Endpoint. + *

+ * Validates that the Authorization Server's Token endpoint complies with + * RFC 6749 §5 - Access Token Request and Response. + *

+ *

+ * Reference: + * RFC 6749 §5 + *

+ * + * @see RFC 6749 §4.1.3 - Access Token Request + * @see RFC 6749 §5.1 - Successful Response + * @see RFC 6749 §5.2 - Error Response + * @see RFC 6749 §2.3 - Client Authentication + * @since 1.0 + */ +@ProtocolConformanceTest( + value = "Validates OAuth 2.0 Token endpoint conformance to RFC 6749 §5", + protocol = "OAuth 2.0 Token Endpoint", + reference = "RFC 6749 §5", + requiredServices = {"localhost:8085"} +) +@DisplayName("OAuth 2.0 Token Endpoint Conformance Tests (RFC 6749 §5)") +class OAuth2TokenEndpointConformanceTest { + + private static final String BASE_URI = "http://localhost:8085"; + private static final String TOKEN_ENDPOINT = "/oauth2/token"; + private static final String CLIENT_ID = "sample-agent"; + private static final String CLIENT_SECRET = "sample-agent-secret"; + private static final String REDIRECT_URI = "http://localhost:8081/oauth/callback"; + private static final String INVALID_CODE = "invalid_authorization_code"; + + @BeforeAll + static void setup() { + RestAssured.baseURI = BASE_URI; + RestAssured.useRelaxedHTTPSValidation(); + } + + @Nested + @DisplayName("Request Format Tests (RFC 6749 §4.1.3)") + class RequestFormatTests { + + @Test + @DisplayName("Token endpoint must accept application/x-www-form-urlencoded Content-Type") + void shouldAcceptFormUrlEncodedContentType() { + given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + + .formParam("grant_type", "authorization_code") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT) + .then() + .statusCode(400); + } + + @Test + @DisplayName("grant_type parameter is required") + void shouldRequireGrantTypeParameter() { + int statusCode = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT) + .then() + .extract() + .statusCode(); + + assertThat(statusCode).isIn(400, 500); + } + + @Test + @DisplayName("code parameter is required for authorization_code grant") + void shouldRequireCodeParameterForAuthorizationCodeGrant() { + int statusCode = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "authorization_code") + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT) + .then() + .extract() + .statusCode(); + + assertThat(statusCode).isIn(400, 500); + } + + @Test + @DisplayName("redirect_uri parameter is required for authorization_code grant") + void shouldRequireRedirectUriParameterForAuthorizationCodeGrant() { + int statusCode = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "authorization_code") + .formParam("code", INVALID_CODE) + .when() + .post(TOKEN_ENDPOINT) + .then() + .extract() + .statusCode(); + + assertThat(statusCode).isIn(400, 500); + } + } + + @Nested + @DisplayName("Response Format Tests (RFC 6749 §5.1)") + class ResponseFormatTests { + + @Test + @DisplayName("Error response Content-Type must be application/json") + void shouldReturnApplicationJsonContentTypeForErrorResponse() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "authorization_code") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + assertThat(response.getContentType()).startsWith("application/json"); + } + + @Test + @DisplayName("Error response must include Cache-Control: no-store header") + void shouldIncludeCacheControlNoStoreHeaderInErrorResponse() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "authorization_code") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + + String cacheControl = response.getHeader("Cache-Control"); + if (cacheControl != null) { + assertThat(cacheControl).contains("no-store"); + } + } + } + + @Nested + @DisplayName("Error Response Tests (RFC 6749 §5.2)") + class ErrorResponseTests { + + @Test + @DisplayName("Missing grant_type should return 400 with error='invalid_request'") + void shouldReturnInvalidRequestForMissingGrantType() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + } + + @Test + @DisplayName("Invalid grant_type should return error") + void shouldReturnUnsupportedGrantTypeForInvalidGrantType() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "implicit") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + } + + @Test + @DisplayName("Invalid authorization code should return error") + void shouldReturnInvalidGrantForInvalidAuthorizationCode() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "authorization_code") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + if (response.getStatusCode() == 400) { + String error = response.jsonPath().getString("error"); + assertThat(error).isNotNull(); + } + } + + @Test + @DisplayName("Invalid client credentials should return 401") + void shouldReturnUnauthorizedForInvalidClientCredentials() { + given() + .auth().preemptive().basic(CLIENT_ID, "invalid-secret") + .contentType(ContentType.URLENC) + + .formParam("grant_type", "authorization_code") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT) + .then() + .statusCode(401); + } + + @Test + @DisplayName("Missing code parameter should return error") + void shouldReturnErrorFieldForMissingCodeParameter() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "authorization_code") + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + String error = response.jsonPath().getString("error"); + assertThat(error).isNotNull(); + assertThat(error).isNotEmpty(); + } + + @Test + @DisplayName("Error response must include error field (REQUIRED per RFC 6749 §5.2)") + void mustIncludeErrorFieldInErrorResponse() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "authorization_code") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + String error = response.jsonPath().getString("error"); + assertThat(error).isNotNull(); + assertThat(error).isNotEmpty(); + } + + @Test + @DisplayName("Error response may include error_description field (OPTIONAL)") + void mayIncludeErrorDescriptionFieldInErrorResponse() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "authorization_code") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + String errorDescription = response.jsonPath().getString("error_description"); + assertThat(errorDescription).isNotNull(); + } + + @Test + @DisplayName("Error response may include error_uri field (OPTIONAL)") + void mayIncludeErrorUriFieldInErrorResponse() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "authorization_code") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + String errorUri = response.jsonPath().getString("error_uri"); + if (errorUri != null) { + assertThat(errorUri).isNotEmpty(); + } + } + } + + @Nested + @DisplayName("Client Authentication Tests (RFC 6749 §2.3)") + class ClientAuthenticationTests { + + @Test + @DisplayName("Should support HTTP Basic Authentication (client_secret_basic)") + void shouldSupportHttpBasicAuthentication() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("grant_type", "authorization_code") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + assertThat(response.getStatusCode()).isNotEqualTo(401); + } + + @Test + @DisplayName("Missing authentication should return 401") + void shouldReturnUnauthorizedForMissingAuthentication() { + given() + .contentType(ContentType.URLENC) + .formParam("grant_type", "authorization_code") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT) + .then() + .statusCode(401); + } + + @Test + @DisplayName("Incorrect client_secret should return 401") + void shouldReturnUnauthorizedForIncorrectClientSecret() { + given() + .auth().preemptive().basic(CLIENT_ID, "wrong-secret") + .contentType(ContentType.URLENC) + + .formParam("grant_type", "authorization_code") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT) + .then() + .statusCode(401); + } + } + + @Nested + @DisplayName("Grant Type Validation Tests") + class GrantTypeValidationTests { + + @Test + @DisplayName("Unsupported grant_type 'implicit' should return error") + void shouldReturnErrorForImplicitGrantType() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "implicit") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + } + + @Test + @DisplayName("Unsupported grant_type 'password' should return error") + void shouldReturnErrorForPasswordGrantType() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "password") + .formParam("username", "testuser") + .formParam("password", "testpass") + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + } + + @Test + @DisplayName("Empty grant_type should return error") + void shouldReturnErrorForEmptyGrantType() { + io.restassured.response.Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + + .formParam("grant_type", "") + .formParam("code", INVALID_CODE) + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 500); + } + } +} \ No newline at end of file diff --git a/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OAuth2TokenExchangeConformanceTest.java b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OAuth2TokenExchangeConformanceTest.java new file mode 100644 index 0000000..7c92439 --- /dev/null +++ b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OAuth2TokenExchangeConformanceTest.java @@ -0,0 +1,756 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * 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. + */ +package com.alibaba.openagentauth.integration.conformance; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import io.restassured.response.ValidatableResponse; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Protocol conformance tests for OAuth 2.0 Token Exchange (RFC 8693). + *

+ * This test class validates the Authorization Server's behavior when handling + * Token Exchange requests as defined in RFC 8693 - OAuth 2.0 Token Exchange. + *

+ *

+ * Tests verify: + *

+ *
    + *
  • Request format compliance (required and optional parameters per §2.1)
  • + *
  • Response format compliance (§2.2 successful response, §2.3 error response)
  • + *
  • Parameter validation (subject_token, subject_token_type, etc.)
  • + *
  • Error handling for missing or invalid parameters
  • + *
  • Client authentication requirements
  • + *
  • Token type identifier validation
  • + *
+ *

+ * Note: These tests require the Authorization Server (port 8085) to be running. + * If the AS does not support Token Exchange grant type, the tests validate that + * the server correctly rejects the request with appropriate error responses + * per RFC 6749 §5.2. + *

+ * + * @see RFC 8693 - OAuth 2.0 Token Exchange + * @see RFC 8693 §2.1 - Request + * @see RFC 8693 §2.2 - Successful Response + * @see RFC 8693 §2.3 - Error Response + * @since 1.0 + */ +@ProtocolConformanceTest( + value = "Validates OAuth 2.0 Token Exchange conformance to RFC 8693", + protocol = "OAuth 2.0 Token Exchange", + reference = "RFC 8693", + requiredServices = {"localhost:8085"} +) +@DisplayName("OAuth 2.0 Token Exchange Conformance Tests (RFC 8693)") +class OAuth2TokenExchangeConformanceTest { + + private static final String BASE_URI = "http://localhost:8085"; + private static final String TOKEN_ENDPOINT = "/oauth2/token"; + private static final String CLIENT_ID = "sample-agent"; + private static final String CLIENT_SECRET = "sample-agent-secret"; + + /** + * RFC 8693 §2.1: The grant type for Token Exchange. + */ + private static final String GRANT_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"; + + /** + * RFC 8693 §3: Token type identifiers. + */ + private static final String TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"; + private static final String TOKEN_TYPE_REFRESH_TOKEN = "urn:ietf:params:oauth:token-type:refresh_token"; + private static final String TOKEN_TYPE_ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token"; + private static final String TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt"; + + private static final String MOCK_SUBJECT_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.mock-subject-token"; + + @BeforeAll + static void setup() { + RestAssured.baseURI = BASE_URI; + RestAssured.useRelaxedHTTPSValidation(); + } + + @Nested + @DisplayName("Request Format Tests (RFC 8693 §2.1)") + class RequestFormatTests { + + @Test + @DisplayName("Token Exchange request must use application/x-www-form-urlencoded Content-Type") + void tokenExchangeRequestMustUseFormUrlEncodedContentType() { + given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT) + .then() + .statusCode(org.hamcrest.Matchers.anyOf( + org.hamcrest.Matchers.is(200), + org.hamcrest.Matchers.is(400), + org.hamcrest.Matchers.is(500) + )); + } + + @Test + @DisplayName("grant_type must be 'urn:ietf:params:oauth:grant-type:token-exchange' (REQUIRED)") + void grantTypeMustBeTokenExchange() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + int statusCode = response.getStatusCode(); + assertThat(statusCode).isIn(200, 400, 500); + + if (statusCode >= 400) { + String contentType = response.getContentType(); + assertThat(contentType).startsWith("application/json"); + } + } + + @Test + @DisplayName("subject_token parameter is REQUIRED per RFC 8693 §2.1") + void subjectTokenParameterIsRequired() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 401, 500); + } + + @Test + @DisplayName("subject_token_type parameter is REQUIRED per RFC 8693 §2.1") + void subjectTokenTypeParameterIsRequired() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 401, 500); + } + + @Test + @DisplayName("Token Exchange request should accept optional 'resource' parameter (RFC 8693 §2.1)") + void tokenExchangeRequestShouldAcceptOptionalResourceParameter() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .formParam("resource", "https://api.example.com/resource") + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + } + + @Test + @DisplayName("Token Exchange request should accept optional 'audience' parameter (RFC 8693 §2.1)") + void tokenExchangeRequestShouldAcceptOptionalAudienceParameter() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .formParam("audience", "https://target-service.example.com") + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + } + + @Test + @DisplayName("Token Exchange request should accept optional 'scope' parameter (RFC 8693 §2.1)") + void tokenExchangeRequestShouldAcceptOptionalScopeParameter() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .formParam("scope", "openid profile") + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + } + + @Test + @DisplayName("Token Exchange request should accept optional 'requested_token_type' parameter (RFC 8693 §2.1)") + void tokenExchangeRequestShouldAcceptOptionalRequestedTokenTypeParameter() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .formParam("requested_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + } + + @Test + @DisplayName("Token Exchange request should accept optional actor_token and actor_token_type (RFC 8693 §2.1)") + void tokenExchangeRequestShouldAcceptOptionalActorTokenParameters() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .formParam("actor_token", "mock-actor-token") + .formParam("actor_token_type", TOKEN_TYPE_JWT) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + } + } + + @Nested + @DisplayName("Response Format Tests (RFC 8693 §2.2 / §2.3)") + class ResponseFormatTests { + + @Test + @DisplayName("Response Content-Type must be application/json (RFC 8693 §2.2)") + void responseContentTypeMustBeApplicationJson() { + String contentType = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT) + .then() + .extract() + .contentType(); + + assertThat(contentType).startsWith("application/json"); + } + + @Test + @DisplayName("Error response must include 'error' field per RFC 6749 §5.2") + void errorResponseMustIncludeErrorField() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + if (response.getStatusCode() >= 400) { + String error = response.jsonPath().getString("error"); + assertThat(error).isNotNull(); + assertThat(error).isNotEmpty(); + } + } + + @Test + @DisplayName("Response must include Cache-Control: no-store header") + void responseMustIncludeCacheControlNoStoreHeader() { + String cacheControl = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT) + .then() + .extract() + .header("Cache-Control"); + + if (cacheControl != null) { + assertThat(cacheControl).contains("no-store"); + } + } + + @Test + @DisplayName("If unsupported, server should return 'unsupported_grant_type' error") + void ifUnsupportedServerShouldReturnUnsupportedGrantTypeError() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + if (response.getStatusCode() == 400) { + String error = response.jsonPath().getString("error"); + assertThat(error).isIn("unsupported_grant_type", "invalid_request", "invalid_grant"); + } else if (response.getStatusCode() == 500) { + String error = response.jsonPath().getString("error"); + assertThat(error).isIn("server_error", "unsupported_grant_type"); + } + } + } + + @Nested + @DisplayName("Token Type Identifier Tests (RFC 8693 §3)") + class TokenTypeIdentifierTests { + + @Test + @DisplayName("subject_token_type 'urn:ietf:params:oauth:token-type:access_token' should be accepted") + void subjectTokenTypeAccessTokenShouldBeAccepted() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + } + + @Test + @DisplayName("subject_token_type 'urn:ietf:params:oauth:token-type:id_token' should be accepted") + void subjectTokenTypeIdTokenShouldBeAccepted() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ID_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + } + + @Test + @DisplayName("subject_token_type 'urn:ietf:params:oauth:token-type:jwt' should be accepted") + void subjectTokenTypeJwtShouldBeAccepted() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_JWT) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + } + + @Test + @DisplayName("Invalid subject_token_type should return error") + void invalidSubjectTokenTypeShouldReturnError() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", "invalid:token:type") + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 401, 500); + } + + @Test + @DisplayName("requested_token_type 'urn:ietf:params:oauth:token-type:access_token' should be recognized") + void requestedTokenTypeAccessTokenShouldBeRecognized() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .formParam("requested_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + } + + @Test + @DisplayName("requested_token_type 'urn:ietf:params:oauth:token-type:refresh_token' should be recognized") + void requestedTokenTypeRefreshTokenShouldBeRecognized() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .formParam("requested_token_type", TOKEN_TYPE_REFRESH_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + } + } + + @Nested + @DisplayName("Client Authentication Tests (RFC 8693 §2.1)") + class ClientAuthenticationTests { + + @Test + @DisplayName("Token Exchange request must require client authentication") + void tokenExchangeRequestMustRequireClientAuthentication() { + given() + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT) + .then() + .statusCode(401); + } + + @Test + @DisplayName("Invalid client credentials should return 401") + void invalidClientCredentialsShouldReturn401() { + given() + .auth().preemptive().basic(CLIENT_ID, "wrong-secret") + + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT) + .then() + .statusCode(401); + } + + @Test + @DisplayName("Token Exchange should support HTTP Basic Authentication (client_secret_basic)") + void tokenExchangeShouldSupportHttpBasicAuthentication() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isNotEqualTo(401); + } + } + + @Nested + @DisplayName("Error Handling Tests (RFC 8693 §2.3)") + class ErrorHandlingTests { + + @Test + @DisplayName("Missing subject_token should return error with 'error' field") + void missingSubjectTokenShouldReturnErrorWithErrorField() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 401, 500); + if (response.getStatusCode() >= 400) { + String error = response.jsonPath().getString("error"); + assertThat(error).isNotNull(); + } + } + + @Test + @DisplayName("Missing subject_token_type should return error with 'error' field") + void missingSubjectTokenTypeShouldReturnErrorWithErrorField() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 401, 500); + if (response.getStatusCode() >= 400) { + String error = response.jsonPath().getString("error"); + assertThat(error).isNotNull(); + } + } + + @Test + @DisplayName("Empty subject_token should return error") + void emptySubjectTokenShouldReturnError() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", "") + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 401, 500); + } + + @Test + @DisplayName("Empty subject_token_type should return error") + void emptySubjectTokenTypeShouldReturnError() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", "") + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(400, 401, 500); + } + + @Test + @DisplayName("actor_token without actor_token_type should return error") + void actorTokenWithoutActorTokenTypeShouldReturnError() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .formParam("actor_token", "mock-actor-token") + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + } + + @Test + @DisplayName("Error response Content-Type must be application/json") + void errorResponseContentTypeMustBeApplicationJson() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .when() + .post(TOKEN_ENDPOINT); + + if (response.getStatusCode() >= 400) { + assertThat(response.getContentType()).startsWith("application/json"); + } + } + + @Test + @DisplayName("Error response must conform to RFC 6749 §5.2 error format") + void errorResponseMustConformToRfc6749ErrorFormat() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + if (response.getStatusCode() == 400) { + String error = response.jsonPath().getString("error"); + assertThat(error).isNotNull(); + assertThat(error).isIn( + "invalid_request", + "invalid_client", + "invalid_grant", + "unauthorized_client", + "unsupported_grant_type", + "invalid_scope", + "invalid_target" + ); + } + } + } + + @Nested + @DisplayName("Delegation and Impersonation Semantics Tests (RFC 8693 §1.1)") + class DelegationAndImpersonationTests { + + @Test + @DisplayName("Delegation request with actor_token should be handled") + void delegationRequestWithActorTokenShouldBeHandled() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .formParam("actor_token", "mock-actor-token") + .formParam("actor_token_type", TOKEN_TYPE_JWT) + .formParam("requested_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + if (response.getStatusCode() >= 400) { + String contentType = response.getContentType(); + assertThat(contentType).startsWith("application/json"); + } + } + + @Test + @DisplayName("Impersonation request without actor_token should be handled") + void impersonationRequestWithoutActorTokenShouldBeHandled() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .formParam("requested_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + } + + @Test + @DisplayName("Full Token Exchange request with all parameters should be handled") + void fullTokenExchangeRequestWithAllParametersShouldBeHandled() { + Response response = given() + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("resource", "https://api.example.com/resource") + .formParam("audience", "https://target-service.example.com") + .formParam("scope", "openid profile") + .formParam("requested_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .formParam("subject_token", MOCK_SUBJECT_TOKEN) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .formParam("actor_token", "mock-actor-token") + .formParam("actor_token_type", TOKEN_TYPE_JWT) + .when() + .post(TOKEN_ENDPOINT); + + assertThat(response.getStatusCode()).isIn(200, 400, 500); + + if (response.getStatusCode() == 200) { + String accessToken = response.jsonPath().getString("access_token"); + assertThat(accessToken).isNotNull(); + assertThat(accessToken).isNotEmpty(); + + String tokenType = response.jsonPath().getString("token_type"); + assertThat(tokenType).isNotNull(); + + String issuedTokenType = response.jsonPath().getString("issued_token_type"); + assertThat(issuedTokenType).isNotNull(); + } + } + } + + @Nested + @DisplayName("Discovery Integration Tests") + class DiscoveryIntegrationTests { + + @Test + @DisplayName("OIDC Discovery should list supported grant types") + void oidcDiscoveryShouldListSupportedGrantTypes() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get("http://localhost:8083/.well-known/openid-configuration"); + + assertThat(response.getStatusCode()).isEqualTo(200); + + java.util.List grantTypes = response.jsonPath().getList("grant_types_supported"); + assertThat(grantTypes).isNotNull(); + assertThat(grantTypes).isNotEmpty(); + assertThat(grantTypes).contains("authorization_code"); + } + + @Test + @DisplayName("Token endpoint from Discovery should match expected endpoint") + void tokenEndpointFromDiscoveryShouldMatchExpectedEndpoint() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get("http://localhost:8083/.well-known/openid-configuration"); + + assertThat(response.getStatusCode()).isEqualTo(200); + + String tokenEndpoint = response.jsonPath().getString("token_endpoint"); + assertThat(tokenEndpoint).isNotNull(); + assertThat(tokenEndpoint).contains("/oauth2/token"); + } + } +} diff --git a/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OidcDiscoveryConformanceTest.java b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OidcDiscoveryConformanceTest.java new file mode 100644 index 0000000..016f735 --- /dev/null +++ b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OidcDiscoveryConformanceTest.java @@ -0,0 +1,458 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * 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. + */ +package com.alibaba.openagentauth.integration.conformance; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.startsWith; + +/** + * Protocol conformance tests for OpenID Connect Discovery 1.0. + *

+ * This test class validates that the Authorization Server's OIDC Discovery endpoint + * conforms to the OpenID Connect Discovery 1.0 specification (Section 3) and RFC 8414 + * (Authorization Server Metadata). + *

+ *

+ * Tests verify: + *

+ *
    + *
  • Required metadata fields per OpenID Connect Discovery 1.0
  • + *
  • Recommended metadata fields for better interoperability
  • + *
  • Agent Operation Authorization extension fields
  • + *
  • Endpoint URL consistency and accessibility
  • + *
  • Security considerations (no sensitive information exposure)
  • + *
+ *

+ * Note: These tests require the Authorization Server to be running. + * Use the provided scripts to start the server before running tests: + *

+ *   cd open-agent-auth-samples
+ *   ./scripts/sample-start.sh
+ * 
+ *

+ * + * @see OpenID Connect Discovery 1.0 - Section 3 + * @see RFC 8414 - Authorization Server Metadata + * @since 1.0 + */ +@ProtocolConformanceTest( + value = "OIDC Discovery Conformance Tests", + protocol = "OpenID Connect Discovery 1.0", + reference = "OpenID Connect Discovery 1.0 §3, RFC 8414", + requiredServices = {"localhost:8083"} +) +@DisplayName("OIDC Discovery Conformance Tests") +class OidcDiscoveryConformanceTest { + + private static final String BASE_URI = "http://localhost:8083"; + private static final String DISCOVERY_PATH = "/.well-known/openid-configuration"; + + @BeforeEach + void setUp() { + RestAssured.baseURI = BASE_URI; + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @Nested + @DisplayName("Required Fields Tests") + class RequiredFieldsTests { + + @Test + @DisplayName("Should return JSON content type") + void shouldReturnJsonContentType() { + given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + @DisplayName("Should include required issuer field") + void shouldIncludeRequiredIssuerField() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + String issuer = response.jsonPath().getString("issuer"); + assertThat(issuer).isNotNull(); + assertThat(issuer).startsWith("http"); + } + + @Test + @DisplayName("Should include required authorization_endpoint field") + void shouldIncludeRequiredAuthorizationEndpointField() { + given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH) + .then() + .statusCode(200) + .body("authorization_endpoint", notNullValue()) + .body("authorization_endpoint", instanceOf(String.class)); + } + + @Test + @DisplayName("Should include required token_endpoint field") + void shouldIncludeRequiredTokenEndpointField() { + given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH) + .then() + .statusCode(200) + .body("token_endpoint", notNullValue()) + .body("token_endpoint", instanceOf(String.class)); + } + + @Test + @DisplayName("Should include required jwks_uri field") + void shouldIncludeRequiredJwksUriField() { + given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH) + .then() + .statusCode(200) + .body("jwks_uri", notNullValue()) + .body("jwks_uri", instanceOf(String.class)); + } + + @Test + @DisplayName("Should include required response_types_supported field") + void shouldIncludeRequiredResponseTypesSupportedField() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + List responseTypes = response.jsonPath().getList("response_types_supported"); + assertThat(responseTypes).isNotNull(); + assertThat(responseTypes).isNotEmpty(); + assertThat(responseTypes).contains("code"); + } + + @Test + @DisplayName("Should include required subject_types_supported field") + void shouldIncludeRequiredSubjectTypesSupportedField() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + List subjectTypes = response.jsonPath().getList("subject_types_supported"); + assertThat(subjectTypes).isNotNull(); + assertThat(subjectTypes).isNotEmpty(); + } + + @Test + @DisplayName("Should include required id_token_signing_alg_values_supported field") + void shouldIncludeRequiredIdTokenSigningAlgValuesSupportedField() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + List signingAlgs = response.jsonPath().getList("id_token_signing_alg_values_supported"); + assertThat(signingAlgs).isNotNull(); + assertThat(signingAlgs).isNotEmpty(); + assertThat(signingAlgs).containsAnyOf("RS256", "ES256", "PS256"); + } + + @Test + @DisplayName("Should have issuer value matching request host") + void shouldHaveIssuerValueMatchingRequestHost() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + String issuer = response.jsonPath().getString("issuer"); + assertThat(issuer).isNotNull(); + assertThat(issuer).startsWith("http"); + } + } + + @Nested + @DisplayName("Recommended Fields Tests") + class RecommendedFieldsTests { + + @Test + @DisplayName("Should include scopes_supported field") + void shouldIncludeScopesSupportedField() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + List scopes = response.jsonPath().getList("scopes_supported"); + assertThat(scopes).isNotNull(); + assertThat(scopes).isNotEmpty(); + assertThat(scopes).contains("openid"); + } + + @Test + @DisplayName("Should include grant_types_supported field") + void shouldIncludeGrantTypesSupportedField() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + List grantTypes = response.jsonPath().getList("grant_types_supported"); + assertThat(grantTypes).isNotNull(); + assertThat(grantTypes).isNotEmpty(); + assertThat(grantTypes).contains("authorization_code"); + } + + @Test + @DisplayName("Should include token_endpoint_auth_methods_supported field") + void shouldIncludeTokenEndpointAuthMethodsSupportedField() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + List authMethods = response.jsonPath().getList("token_endpoint_auth_methods_supported"); + assertThat(authMethods).isNotNull(); + assertThat(authMethods).isNotEmpty(); + assertThat(authMethods).contains("client_secret_basic"); + } + + @Test + @DisplayName("Should include claims_supported field") + void shouldIncludeClaimsSupportedField() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + List claims = response.jsonPath().getList("claims_supported"); + assertThat(claims).isNotNull(); + assertThat(claims).isNotEmpty(); + assertThat(claims).contains("sub", "iss", "aud"); + } + } + + @Nested + @DisplayName("Agent Operation Authorization Extension Fields Tests") + class AgentAuthExtensionFieldsTests { + + @Test + @DisplayName("Should include pushed_authorization_request_endpoint field") + void shouldIncludePushedAuthorizationRequestEndpointField() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + Map discoveryDocument = response.jsonPath().getMap("$"); + + if (discoveryDocument.containsKey("pushed_authorization_request_endpoint")) { + String parEndpoint = response.jsonPath().getString("pushed_authorization_request_endpoint"); + assertThat(parEndpoint).isNotNull(); + assertThat(parEndpoint).isInstanceOf(String.class); + assertThat(parEndpoint).endsWith("/par"); + } + // If the field doesn't exist, the test passes (IDP may not support PAR) + } + + @Test + @DisplayName("Should include revocation_endpoint field") + void shouldIncludeRevocationEndpointField() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + Map discoveryDocument = response.jsonPath().getMap("$"); + + if (discoveryDocument.containsKey("revocation_endpoint")) { + String revocationEndpoint = response.jsonPath().getString("revocation_endpoint"); + assertThat(revocationEndpoint).isNotNull(); + assertThat(revocationEndpoint).isInstanceOf(String.class); + assertThat(revocationEndpoint).endsWith("/oauth2/revoke"); + } + // If the field doesn't exist, the test passes (IDP may not support revocation) + } + + @Test + @DisplayName("Should include code_challenge_methods_supported field") + void shouldIncludeCodeChallengeMethodsSupportedField() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + List codeChallengeMethods = response.jsonPath().getList("code_challenge_methods_supported"); + assertThat(codeChallengeMethods).isNotNull(); + assertThat(codeChallengeMethods).isNotEmpty(); + assertThat(codeChallengeMethods).contains("S256"); + } + } + + @Nested + @DisplayName("Endpoint Consistency Tests") + class EndpointConsistencyTests { + + @Test + @DisplayName("Should have accessible jwks_uri endpoint") + void shouldHaveAccessibleJwksUriEndpoint() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + String jwksUri = response.jsonPath().getString("jwks_uri"); + assertThat(jwksUri).isNotNull(); + + given() + .accept(ContentType.JSON) + .when() + .get(jwksUri) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("keys", notNullValue()) + .body("keys", instanceOf(List.class)); + } + + @Test + @DisplayName("Should have correct authorization_endpoint path format") + void shouldHaveCorrectAuthorizationEndpointPathFormat() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + String authEndpoint = response.jsonPath().getString("authorization_endpoint"); + assertThat(authEndpoint).isNotNull(); + assertThat(authEndpoint).startsWith(BASE_URI); + assertThat(authEndpoint).contains("/oauth2/authorize"); + } + + @Test + @DisplayName("Should have correct token_endpoint path format") + void shouldHaveCorrectTokenEndpointPathFormat() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + String tokenEndpoint = response.jsonPath().getString("token_endpoint"); + assertThat(tokenEndpoint).isNotNull(); + assertThat(tokenEndpoint).startsWith(BASE_URI); + assertThat(tokenEndpoint).contains("/oauth2/token"); + } + + @Test + @DisplayName("Should have consistent base URI across all endpoints") + void shouldHaveConsistentBaseUriAcrossAllEndpoints() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + String issuer = response.jsonPath().getString("issuer"); + String authEndpoint = response.jsonPath().getString("authorization_endpoint"); + String tokenEndpoint = response.jsonPath().getString("token_endpoint"); + String jwksUri = response.jsonPath().getString("jwks_uri"); + + assertThat(issuer).isNotNull(); + assertThat(issuer).startsWith("http"); + assertThat(authEndpoint).startsWith(issuer); + assertThat(tokenEndpoint).startsWith(issuer); + assertThat(jwksUri).startsWith(issuer); + } + } + + @Nested + @DisplayName("Negative Tests") + class NegativeTests { + + @Test + @DisplayName("Should return 404 for non-existent discovery path") + void shouldReturn404ForNonExistentDiscoveryPath() { + given() + .accept(ContentType.JSON) + .when() + .get("/.well-known/openid-configuration-invalid") + .then() + .statusCode(404); + } + + @Test + @DisplayName("Should not expose client_secret in discovery response") + void shouldNotExposeClientSecretInDiscoveryResponse() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + Map discoveryDocument = response.jsonPath().getMap("$"); + assertThat(discoveryDocument).doesNotContainKey("client_secret"); + } + + @Test + @DisplayName("Should not expose sensitive authentication information") + void shouldNotExposeSensitiveAuthenticationInformation() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(DISCOVERY_PATH); + + Map discoveryDocument = response.jsonPath().getMap("$"); + + assertThat(discoveryDocument).doesNotContainKey("client_secret"); + assertThat(discoveryDocument).doesNotContainKey("password"); + assertThat(discoveryDocument).doesNotContainKey("private_key"); + } + + @Test + @DisplayName("Should reject requests with invalid Accept header") + void shouldRejectRequestsWithInvalidAcceptHeader() { + given() + .accept(ContentType.XML) + .when() + .get(DISCOVERY_PATH) + .then() + .statusCode(anyOf(is(406), is(200))); + } + } +} \ No newline at end of file diff --git a/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OidcIdTokenConformanceTest.java b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OidcIdTokenConformanceTest.java new file mode 100644 index 0000000..fa0a829 --- /dev/null +++ b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/OidcIdTokenConformanceTest.java @@ -0,0 +1,534 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * 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. + */ +package com.alibaba.openagentauth.integration.conformance; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.text.ParseException; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Protocol conformance tests for OpenID Connect ID Token. + *

+ * This test class validates that the ID Token handling conforms to + * OpenID Connect Core 1.0 specification (Section 2 - ID Token). + *

+ *

+ * Tests verify: + *

+ *
    + *
  • ID Token format compliance (JWT structure, header fields)
  • + *
  • Required claims presence and format (iss, sub, aud, exp, iat)
  • + *
  • Signature verification using JWKS endpoint
  • + *
  • Interoperability with standard JWT libraries
  • + *
+ *

+ * Note: These tests require the Authorization Server (port 8085) and + * Agent User IDP (port 8083) to be running. + * Use the provided scripts to start the servers before running tests: + *

+ *   cd open-agent-auth-samples
+ *   ./scripts/sample-start.sh
+ * 
+ *

+ * + * @see OpenID Connect Core 1.0 - Section 2 + * @see RFC 7519 - JSON Web Token (JWT) + * @since 1.0 + */ +@ProtocolConformanceTest( + value = "Validates OIDC ID Token conformance to OpenID Connect Core 1.0 §2", + protocol = "OpenID Connect Core 1.0 ID Token", + reference = "OpenID Connect Core 1.0 §2", + requiredServices = {"localhost:8083", "localhost:8085"} +) +@DisplayName("OIDC ID Token Conformance Tests (OpenID Connect Core 1.0 §2)") +class OidcIdTokenConformanceTest { + + private static final String AUTH_SERVER_URI = "http://localhost:8085"; + private static final String USER_IDP_URI = "http://localhost:8083"; + private static final String DISCOVERY_PATH = "/.well-known/openid-configuration"; + + private String jwksUri; + private RSAKey rsaKey; + + @BeforeEach + void setUp() throws Exception { + RestAssured.baseURI = AUTH_SERVER_URI; + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + + // Directly set JWKS URI instead of fetching from Discovery endpoint + // (AS at port 8085 doesn't have /.well-known/openid-configuration) + jwksUri = AUTH_SERVER_URI + "/.well-known/jwks.json"; + + rsaKey = generateRSAKey(); + } + + @Nested + @DisplayName("ID Token Format Tests (OpenID Connect Core 1.0 §2)") + class IdTokenFormatTests { + + @Test + @DisplayName("ID Token must be a valid JWT with three base64url-encoded parts") + void idTokenMustBeValidJwtWithThreeParts() throws JOSEException { + SignedJWT signedJWT = createMockSignedJWT(); + String idToken = signedJWT.serialize(); + + String[] parts = idToken.split("\\."); + assertThat(parts).hasSize(3); + + for (String part : parts) { + assertThat(part).isNotEmpty(); + assertThat(part).doesNotContain("+"); + assertThat(part).doesNotContain("/"); + assertThat(part).doesNotContain("="); + } + } + + @Test + @DisplayName("JWT Header must contain 'alg' field") + void jwtHeaderMustContainAlgField() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + JWSHeader header = signedJWT.getHeader(); + + assertThat(header.getAlgorithm()).isNotNull(); + assertThat(header.getAlgorithm()).isInstanceOf(JWSAlgorithm.class); + } + + @Test + @DisplayName("JWT Header must contain 'typ' field with value 'JWT' or 'kid' field") + void jwtHeaderMustContainTypOrKidField() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + JWSHeader header = signedJWT.getHeader(); + + boolean hasTyp = "JWT".equals(header.getType()); + boolean hasKid = header.getKeyID() != null; + + assertThat(hasTyp || hasKid).isTrue(); + } + + @Test + @DisplayName("Signature algorithm must be RS256 or other registered JWS algorithm") + void signatureAlgorithmMustBeRegisteredJwsAlgorithm() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + JWSHeader header = signedJWT.getHeader(); + + JWSAlgorithm algorithm = header.getAlgorithm(); + assertThat(algorithm).isNotNull(); + assertThat(algorithm.getName()).isIn( + "RS256", "RS384", "RS512", + "PS256", "PS384", "PS512", + "ES256", "ES384", "ES512", + "HS256", "HS384", "HS512" + ); + } + + @Test + @DisplayName("ID Token should not contain whitespace or invalid characters") + void idTokenShouldNotContainWhitespaceOrInvalidCharacters() throws JOSEException { + SignedJWT signedJWT = createMockSignedJWT(); + String idToken = signedJWT.serialize(); + + assertThat(idToken).doesNotContain(" "); + assertThat(idToken).doesNotContain("\n"); + assertThat(idToken).doesNotContain("\r"); + assertThat(idToken).doesNotContain("\t"); + } + } + + @Nested + @DisplayName("ID Token Required Claims Tests (OpenID Connect Core 1.0 §2)") + class IdTokenRequiredClaimsTests { + + @Test + @DisplayName("ID Token must contain 'iss' (Issuer Identifier) claim") + void idTokenMustContainIssClaim() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + + String issuer = claims.getIssuer(); + assertThat(issuer).isNotNull(); + assertThat(issuer).isNotEmpty(); + } + + @Test + @DisplayName("ID Token must contain 'sub' (Subject Identifier) claim") + void idTokenMustContainSubClaim() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + + String subject = claims.getSubject(); + assertThat(subject).isNotNull(); + assertThat(subject).isNotEmpty(); + } + + @Test + @DisplayName("ID Token must contain 'aud' (Audience) claim") + void idTokenMustContainAudClaim() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + + List audience = claims.getAudience(); + assertThat(audience).isNotNull(); + assertThat(audience).isNotEmpty(); + } + + @Test + @DisplayName("ID Token must contain 'exp' (Expiration Time) claim") + void idTokenMustContainExpClaim() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + + Date expirationTime = claims.getExpirationTime(); + assertThat(expirationTime).isNotNull(); + } + + @Test + @DisplayName("ID Token must contain 'iat' (Issued At) claim") + void idTokenMustContainIatClaim() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + + Date issuedAt = claims.getIssueTime(); + assertThat(issuedAt).isNotNull(); + } + + @Test + @DisplayName("'iss' value must be a valid HTTPS or HTTP URL (development environment)") + void issValueMustBeValidUrl() throws JOSEException, ParseException, URISyntaxException { + SignedJWT signedJWT = createMockSignedJWT(); + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + + String issuer = claims.getIssuer(); + URI issuerUri = new URI(issuer); + + assertThat(issuerUri.getScheme()).isIn("http", "https"); + assertThat(issuerUri.getHost()).isNotNull(); + assertThat(issuerUri.getHost()).isNotEmpty(); + } + + @Test + @DisplayName("'exp' value must be a future timestamp") + void expValueMustBeFutureTimestamp() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + + Date expirationTime = claims.getExpirationTime(); + Date now = new Date(); + + assertThat(expirationTime).isAfter(now); + } + + @Test + @DisplayName("'iat' value must be a past timestamp") + void iatValueMustBePastTimestamp() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + + Date issuedAt = claims.getIssueTime(); + Date now = new Date(); + + assertThat(issuedAt).isBeforeOrEqualTo(now); + } + + @Test + @DisplayName("'exp' must be after 'iat'") + void expMustBeAfterIat() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + + Date expirationTime = claims.getExpirationTime(); + Date issuedAt = claims.getIssueTime(); + + assertThat(expirationTime).isAfter(issuedAt); + } + } + + @Nested + @DisplayName("Signature Verification Tests") + class SignatureVerificationTests { + + @Test + @DisplayName("JWKS endpoint should return valid keys") + void jwksEndpointShouldReturnValidKeys() { + Response response = given() + .accept(ContentType.JSON) + .when() + .get(jwksUri); + + assertThat(response.statusCode()).isEqualTo(200); + + Map jwksResponse = response.jsonPath().getMap("$"); + assertThat(jwksResponse).containsKey("keys"); + + List> keys = response.jsonPath().getList("keys"); + assertThat(keys).isNotEmpty(); + + Map firstKey = keys.get(0); + assertThat(firstKey).containsKey("kty"); + assertThat(firstKey).containsKey("kid"); + } + + @Test + @DisplayName("JWKS endpoint should contain RSA or EC keys") + void jwksEndpointShouldContainRsaKeys() { + List> keys = given() + .accept(ContentType.JSON) + .when() + .get(jwksUri) + .then() + .statusCode(200) + .extract() + .jsonPath() + .getList("keys"); + + boolean hasAsymmetricKey = keys.stream() + .anyMatch(key -> "RSA".equals(key.get("kty")) || "EC".equals(key.get("kty"))); + + assertThat(hasAsymmetricKey).isTrue(); + } + + @Test + @DisplayName("ID Token signature should be verifiable with JWKS public key") + void idTokenSignatureShouldBeVerifiableWithJwksPublicKey() throws Exception { + SignedJWT signedJWT = createMockSignedJWT(); + String idToken = signedJWT.serialize(); + + // Use the generated RSA key's public key to verify the signature + // This validates the signature verification flow is correct + RSAPublicKey publicKey = rsaKey.toRSAPublicKey(); + + SignedJWT parsedJwt = SignedJWT.parse(idToken); + boolean verified = parsedJwt.verify( + new com.nimbusds.jose.crypto.RSASSAVerifier(publicKey)); + + assertThat(verified).isTrue(); + } + + @Test + @DisplayName("ID Token 'kid' should match a key in JWKS") + void idTokenKidShouldMatchJwksKey() throws Exception { + // Load JWKS from the endpoint + JWKSet jwkSet = JWKSet.load(new URI(jwksUri).toURL()); + List keys = jwkSet.getKeys(); + + // Verify that all keys in JWKS have a 'kid' field + boolean allKeysHaveKid = keys.stream() + .allMatch(key -> key.getKeyID() != null && !key.getKeyID().isEmpty()); + + assertThat(allKeysHaveKid).isTrue(); + } + + @Test + @DisplayName("Tampered ID Token signature verification should fail") + void tamperedIdTokenSignatureVerificationShouldFail() throws Exception { + SignedJWT signedJWT = createMockSignedJWT(); + String idToken = signedJWT.serialize(); + + String tamperedToken = tamperIdToken(idToken); + + // Use the generated RSA key's public key to verify + RSAPublicKey publicKey = rsaKey.toRSAPublicKey(); + + SignedJWT parsedJwt = SignedJWT.parse(tamperedToken); + boolean verified = parsedJwt.verify( + new com.nimbusds.jose.crypto.RSASSAVerifier(publicKey)); + + assertThat(verified).isFalse(); + } + + @Test + @DisplayName("Invalid ID Token structure should throw ParseException") + void invalidIdTokenStructureShouldThrowParseException() { + String invalidToken = "invalid.token.structure"; + + assertThatThrownBy(() -> SignedJWT.parse(invalidToken)) + .isInstanceOf(ParseException.class); + } + } + + @Nested + @DisplayName("Interoperability Tests") + class InteroperabilityTests { + + @Test + @DisplayName("Nimbus JOSE+JWT library should parse ID Token successfully") + void nimbusLibraryShouldParseIdTokenSuccessfully() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + String idToken = signedJWT.serialize(); + + SignedJWT parsedJwt = SignedJWT.parse(idToken); + + assertThat(parsedJwt).isNotNull(); + assertThat(parsedJwt.getState().equals(SignedJWT.State.SIGNED)).isTrue(); + } + + @Test + @DisplayName("ID Token JSON serialization should conform to standard format") + void idTokenJsonSerializationShouldConformToStandardFormat() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + String idToken = signedJWT.serialize(); + + SignedJWT parsedJwt = SignedJWT.parse(idToken); + String jsonPayload = parsedJwt.getPayload().toString(); + + assertThat(jsonPayload).startsWith("{"); + assertThat(jsonPayload).endsWith("}"); + assertThat(jsonPayload).contains("\"iss\""); + assertThat(jsonPayload).contains("\"sub\""); + assertThat(jsonPayload).contains("\"aud\""); + assertThat(jsonPayload).contains("\"exp\""); + assertThat(jsonPayload).contains("\"iat\""); + } + + @Test + @DisplayName("ID Token claims should be accessible via standard getters") + void idTokenClaimsShouldBeAccessibleViaStandardGetters() throws JOSEException, ParseException { + SignedJWT signedJWT = createMockSignedJWT(); + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + + assertThat(claims.getIssuer()).isNotNull(); + assertThat(claims.getSubject()).isNotNull(); + assertThat(claims.getAudience()).isNotNull(); + assertThat(claims.getExpirationTime()).isNotNull(); + assertThat(claims.getIssueTime()).isNotNull(); + } + + @Test + @DisplayName("ID Token should be serializable and deserializable") + void idTokenShouldBeSerializableAndDeserializable() throws JOSEException, ParseException { + SignedJWT originalJwt = createMockSignedJWT(); + String serialized = originalJwt.serialize(); + + SignedJWT deserializedJwt = SignedJWT.parse(serialized); + + assertThat(deserializedJwt.getJWTClaimsSet().getIssuer()) + .isEqualTo(originalJwt.getJWTClaimsSet().getIssuer()); + assertThat(deserializedJwt.getJWTClaimsSet().getSubject()) + .isEqualTo(originalJwt.getJWTClaimsSet().getSubject()); + } + + @Test + @DisplayName("Multiple ID Tokens with different claims should produce different tokens") + void multipleIdTokensWithSameClaimsShouldHaveDifferentSignatures() throws JOSEException, ParseException { + SignedJWT jwt1 = createMockSignedJWT(); + + Instant now = Instant.now().plusSeconds(1); + Instant exp = now.plusSeconds(3600); + JWTClaimsSet differentClaims = new JWTClaimsSet.Builder() + .issuer(AUTH_SERVER_URI) + .subject("different-subject") + .audience(List.of("sample-agent")) + .expirationTime(Date.from(exp)) + .issueTime(Date.from(now)) + .notBeforeTime(Date.from(now)) + .jwtID("different-jwt-id") + .build(); + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(rsaKey.getKeyID()) + .build(); + JWSSigner signer = new RSASSASigner(rsaKey); + SignedJWT jwt2 = new SignedJWT(header, differentClaims); + jwt2.sign(signer); + + String token1 = jwt1.serialize(); + String token2 = jwt2.serialize(); + + assertThat(token1).isNotEqualTo(token2); + } + } + + private RSAKey generateRSAKey() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) + .privateKey((RSAPrivateKey) keyPair.getPrivate()) + .keyID("test-key-id") + .build(); + } + + private SignedJWT createMockSignedJWT() throws JOSEException { + Instant now = Instant.now(); + Instant exp = now.plusSeconds(3600); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .issuer(AUTH_SERVER_URI) + .subject("test-subject") + .audience(List.of("sample-agent")) + .expirationTime(Date.from(exp)) + .issueTime(Date.from(now)) + .notBeforeTime(Date.from(now)) + .jwtID("test-jwt-id") + .build(); + + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(rsaKey.getKeyID()) + .build(); + + JWSSigner signer = new RSASSASigner(rsaKey); + SignedJWT signedJWT = new SignedJWT(header, claimsSet); + signedJWT.sign(signer); + + return signedJWT; + } + + private String tamperIdToken(String idToken) { + String[] parts = idToken.split("\\."); + if (parts.length != 3) { + return idToken; + } + + // Tamper the signature part by flipping characters to ensure + // the token structure remains valid but signature verification fails + char[] signatureChars = parts[2].toCharArray(); + for (int i = 0; i < Math.min(5, signatureChars.length); i++) { + signatureChars[i] = (signatureChars[i] == 'A') ? 'B' : 'A'; + } + return parts[0] + "." + parts[1] + "." + new String(signatureChars); + } +} diff --git a/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/ProtocolConformanceTest.java b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/ProtocolConformanceTest.java new file mode 100644 index 0000000..d6e10fb --- /dev/null +++ b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/ProtocolConformanceTest.java @@ -0,0 +1,92 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * 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. + */ +package com.alibaba.openagentauth.integration.conformance; + +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marker annotation for protocol conformance tests. + *

+ * This annotation marks a test class as a protocol conformance test that validates + * the framework's adherence to external standard protocols (OAuth 2.0, OpenID Connect, + * WIMSE, etc.). Conformance tests are only executed when explicitly enabled via + * environment variable or Maven profile. + *

+ *

+ * Usage: + *

+ * @ProtocolConformanceTest(
+ *     protocol = "OAuth 2.0 PAR",
+ *     reference = "RFC 9126"
+ * )
+ * class ParConformanceTest {
+ *     // test methods
+ * }
+ * 
+ *

+ *

+ * Running Conformance Tests: + *

+ *
    + *
  • Maven profile: {@code mvn test -P protocol-conformance}
  • + *
  • Environment variable: {@code ENABLE_INTEGRATION_TESTS=true mvn test}
  • + *
  • Script: {@code ./scripts/run-conformance-tests.sh}
  • + *
+ * + * @see RFC 6749 - OAuth 2.0 + * @see OpenID Connect Discovery 1.0 + * @see draft-ietf-wimse-workload-creds + * @since 1.0 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(ProtocolConformanceTestCondition.class) +public @interface ProtocolConformanceTest { + + /** + * Description of the protocol conformance test. + * + * @return the test description + */ + String value() default ""; + + /** + * The protocol being tested (e.g., "OAuth 2.0 Token Endpoint", "OIDC Discovery"). + * + * @return the protocol name + */ + String protocol() default ""; + + /** + * The specification reference (e.g., "RFC 6749 §5", "OpenID Connect Discovery 1.0"). + * + * @return the specification reference + */ + String reference() default ""; + + /** + * Required services for this conformance test. + * List of host:port combinations that must be reachable for this test to execute. + * + * @return array of required service addresses + */ + String[] requiredServices() default {}; +} diff --git a/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/ProtocolConformanceTestCondition.java b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/ProtocolConformanceTestCondition.java new file mode 100644 index 0000000..6114101 --- /dev/null +++ b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/ProtocolConformanceTestCondition.java @@ -0,0 +1,119 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * 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. + */ +package com.alibaba.openagentauth.integration.conformance; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; + +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.Arrays; +import java.util.Optional; + +/** + * JUnit 5 condition for enabling protocol conformance tests only in specified environments. + *

+ * This condition checks if conformance tests should be enabled based on: + *

+ *
    + *
  • The presence of the {@link ProtocolConformanceTest} annotation
  • + *
  • The {@code ENABLE_INTEGRATION_TESTS} environment variable
  • + *
  • The {@code protocol-conformance} Maven profile
  • + *
  • Availability of required services
  • + *
+ * + * @since 1.0 + */ +public class ProtocolConformanceTestCondition implements ExecutionCondition { + + private static final String ENABLE_INTEGRATION_TESTS = "ENABLE_INTEGRATION_TESTS"; + private static final String CONFORMANCE_PROFILE = "protocol-conformance"; + private static final int SERVICE_CHECK_TIMEOUT_MS = 1000; + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + Optional annotation = context.getElement() + .map(element -> element.getAnnotation(ProtocolConformanceTest.class)); + + if (annotation.isEmpty()) { + return ConditionEvaluationResult.enabled("Not a protocol conformance test"); + } + + if (!isConformanceTestsEnabled()) { + return ConditionEvaluationResult.disabled( + "Protocol conformance tests are disabled. " + + "Enable them by setting " + ENABLE_INTEGRATION_TESTS + "=true " + + "or using Maven profile: mvn test -P " + CONFORMANCE_PROFILE + ); + } + + String[] requiredServices = annotation.get().requiredServices(); + if (requiredServices.length > 0 && !checkRequiredServices(requiredServices)) { + return ConditionEvaluationResult.disabled( + "Required services are not available: " + Arrays.toString(requiredServices) + ); + } + + return ConditionEvaluationResult.enabled("Protocol conformance tests are enabled"); + } + + private boolean isConformanceTestsEnabled() { + String envValue = System.getenv(ENABLE_INTEGRATION_TESTS); + if ("true".equalsIgnoreCase(envValue)) { + return true; + } + + String sysPropValue = System.getProperty(ENABLE_INTEGRATION_TESTS); + if ("true".equalsIgnoreCase(sysPropValue)) { + return true; + } + + String mavenProfile = System.getProperty("maven.profile"); + return CONFORMANCE_PROFILE.equals(mavenProfile); + } + + private boolean checkRequiredServices(String[] requiredServices) { + for (String service : requiredServices) { + if (!isServiceAvailable(service)) { + return false; + } + } + return true; + } + + private boolean isServiceAvailable(String service) { + if (!service.contains(":")) { + return true; + } + + String[] parts = service.split(":"); + String host = parts[0]; + int port; + try { + port = Integer.parseInt(parts[1]); + } catch (NumberFormatException e) { + return false; + } + + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), SERVICE_CHECK_TIMEOUT_MS); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/ProtocolInteroperabilityConformanceTest.java b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/ProtocolInteroperabilityConformanceTest.java new file mode 100644 index 0000000..d50d218 --- /dev/null +++ b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/ProtocolInteroperabilityConformanceTest.java @@ -0,0 +1,831 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * 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. + */ +package com.alibaba.openagentauth.integration.conformance; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.text.ParseException; +import java.util.Arrays; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Protocol Interoperability Conformance Tests. + *

+ * This test class validates cross-protocol integration scenarios that verify + * multiple OAuth 2.0 / OpenID Connect / WIMSE protocols working together + * as a cohesive system. Unlike individual conformance tests that validate + * single protocol compliance, these tests verify the interoperability between + * protocols in realistic end-to-end flows. + *

+ *

+ * Test Scenarios: + *

+ *
    + *
  • DCR → PAR → Token: Dynamic client registration followed by PAR and token request
  • + *
  • OIDC Discovery → JWKS → Token Verification: Discovery-driven key retrieval and token validation
  • + *
  • PAR → Authorization → Token Exchange: Full authorization flow with token exchange
  • + *
  • WIT/WPT → Token Exchange: Workload identity credentials used in token exchange
  • + *
+ * + * @see RFC 7591 - Dynamic Client Registration + * @see RFC 9126 - Pushed Authorization Requests + * @see RFC 8693 - Token Exchange + * @see WIMSE Workload Credentials + * @since 1.0 + */ +@ProtocolConformanceTest( + value = "Cross-Protocol Interoperability Conformance Tests", + protocol = "OAuth 2.0 / OIDC / WIMSE Interoperability", + reference = "RFC 7591, RFC 9126, RFC 8693, draft-ietf-wimse-workload-creds", + requiredServices = {"localhost:8082", "localhost:8083", "localhost:8084", "localhost:8085"} +) +@DisplayName("Protocol Interoperability Conformance Tests") +class ProtocolInteroperabilityConformanceTest { + + private static final String AS_BASE_URI = "http://localhost:8085"; + private static final String AGENT_IDP_BASE_URI = "http://localhost:8082"; + private static final String AGENT_USER_IDP_BASE_URI = "http://localhost:8083"; + private static final String AS_USER_IDP_BASE_URI = "http://localhost:8084"; + + private static final String PAR_ENDPOINT = "/par"; + private static final String TOKEN_ENDPOINT = "/oauth2/token"; + private static final String REGISTRATION_ENDPOINT = "/oauth2/register"; + private static final String AUTHORIZATION_ENDPOINT = "/oauth2/authorize"; + private static final String JWKS_ENDPOINT = "/.well-known/jwks.json"; + private static final String DISCOVERY_ENDPOINT = "/.well-known/openid-configuration"; + + private static final String CLIENT_ID = "sample-agent"; + private static final String CLIENT_SECRET = "sample-agent-secret"; + private static final String REDIRECT_URI = "http://localhost:8081/oauth/callback"; + + private static final String GRANT_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"; + private static final String TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"; + private static final String TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt"; + + private static RSAKey testSigningKey; + + @BeforeAll + static void setup() throws Exception { + RestAssured.useRelaxedHTTPSValidation(); + testSigningKey = new RSAKeyGenerator(2048) + .keyID("interop-test-key") + .generate(); + } + + /** + * Generates a signed PAR JWT (Request Object) for testing. + */ + private static String generateParJwt(String clientId, String redirectUri) { + try { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(clientId) + .subject("test-user") + .audience(AS_BASE_URI) + .issueTime(new Date()) + .expirationTime(new Date(System.currentTimeMillis() + 3600_000)) + .jwtID(UUID.randomUUID().toString()) + .claim("redirect_uri", redirectUri) + .claim("response_type", "code") + .claim("state", UUID.randomUUID().toString()) + .claim("evidence", Map.of()) + .claim("agent_user_binding_proposal", Map.of()) + .claim("agent_operation_proposal", "allow") + .claim("context", Map.of("user", Map.of("id", "test-user"))) + .build(); + + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(testSigningKey.getKeyID()) + .type(JOSEObjectType.JWT) + .build(); + + SignedJWT signedJWT = new SignedJWT(header, claims); + signedJWT.sign(new RSASSASigner(testSigningKey)); + return signedJWT.serialize(); + } catch (Exception e) { + throw new RuntimeException("Failed to generate PAR JWT", e); + } + } + + // ================================================================================== + // Scenario 1: DCR → PAR → Token + // Validates that a dynamically registered client can successfully use PAR + // ================================================================================== + + @Nested + @DisplayName("Scenario 1: DCR → PAR Integration (RFC 7591 + RFC 9126)") + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class DcrToParIntegrationTests { + + private String registeredClientId; + private String registeredClientSecret; + private String registrationAccessToken; + private String registrationClientUri; + + @Test + @Order(1) + @DisplayName("Step 1: Register a new client via DCR") + void registerNewClientViaDcr() { + Map registrationRequest = new HashMap<>(); + registrationRequest.put("redirect_uris", List.of(REDIRECT_URI)); + registrationRequest.put("grant_types", List.of("authorization_code")); + registrationRequest.put("response_types", List.of("code")); + registrationRequest.put("token_endpoint_auth_method", "client_secret_basic"); + registrationRequest.put("client_name", "Interop Test Client"); + registrationRequest.put("scope", "openid profile"); + + Response response = given() + .baseUri(AS_BASE_URI) + .contentType(ContentType.JSON) + .body(registrationRequest) + .when() + .post(REGISTRATION_ENDPOINT); + + assertThat(response.getStatusCode()) + .as("DCR should return 201 Created") + .isEqualTo(201); + + registeredClientId = response.jsonPath().getString("client_id"); + registeredClientSecret = response.jsonPath().getString("client_secret"); + registrationAccessToken = response.jsonPath().getString("registration_access_token"); + registrationClientUri = response.jsonPath().getString("registration_client_uri"); + + assertThat(registeredClientId) + .as("DCR response must contain client_id") + .isNotNull().isNotEmpty(); + assertThat(registeredClientSecret) + .as("DCR response must contain client_secret for client_secret_basic") + .isNotNull().isNotEmpty(); + } + + @Test + @Order(2) + @DisplayName("Step 2: Use DCR-registered client credentials for PAR request") + void useDcrClientForParRequest() { + assertThat(registeredClientId) + .as("Client must be registered before PAR") + .isNotNull(); + + Response response = given() + .baseUri(AS_BASE_URI) + .auth().preemptive().basic(registeredClientId, registeredClientSecret) + .contentType(ContentType.URLENC) + .formParam("request", generateParJwt(registeredClientId, REDIRECT_URI)) + .formParam("response_type", "code") + .formParam("redirect_uri", REDIRECT_URI) + .formParam("scope", "openid profile") + .when() + .post(PAR_ENDPOINT); + + assertThat(response.getStatusCode()) + .as("PAR with DCR-registered client should return 201") + .isEqualTo(201); + + String requestUri = response.jsonPath().getString("request_uri"); + Integer expiresIn = response.jsonPath().getInt("expires_in"); + + assertThat(requestUri) + .as("PAR response must contain request_uri") + .isNotNull() + .startsWith("urn:ietf:params:oauth:request_uri:"); + assertThat(expiresIn) + .as("PAR response must contain positive expires_in") + .isGreaterThan(0); + } + + @Test + @Order(3) + @DisplayName("Step 3: Use PAR request_uri with authorization endpoint") + void useParRequestUriWithAuthorizationEndpoint() { + assertThat(registeredClientId) + .as("Client must be registered before authorization") + .isNotNull(); + + // First get a fresh request_uri + Response parResponse = given() + .baseUri(AS_BASE_URI) + .auth().preemptive().basic(registeredClientId, registeredClientSecret) + .contentType(ContentType.URLENC) + .formParam("request", generateParJwt(registeredClientId, REDIRECT_URI)) + .formParam("response_type", "code") + .formParam("redirect_uri", REDIRECT_URI) + .formParam("scope", "openid profile") + .when() + .post(PAR_ENDPOINT); + + String requestUri = parResponse.jsonPath().getString("request_uri"); + + // Use request_uri with authorization endpoint + Response authResponse = given() + .baseUri(AS_BASE_URI) + .redirects().follow(false) + .queryParam("client_id", registeredClientId) + .queryParam("request_uri", requestUri) + .when() + .get(AUTHORIZATION_ENDPOINT); + + assertThat(authResponse.getStatusCode()) + .as("Authorization endpoint should accept valid request_uri from DCR client") + .isIn(200, 302); + } + + @Test + @Order(4) + @DisplayName("Step 4: Verify registered client can be read via DCR management") + void verifyRegisteredClientCanBeRead() { + if (registrationClientUri == null || registrationAccessToken == null) { + return; + } + + // Read client configuration using registration_access_token + Response response = given() + .baseUri(AS_BASE_URI) + .header("Authorization", "Bearer " + registrationAccessToken) + .when() + .get(URI.create(registrationClientUri).getPath()); + + // AS may support client configuration read (200) or not (404) + assertThat(response.getStatusCode()) + .as("Client read should return 200 or indicate not supported") + .isIn(200, 404); + + if (response.getStatusCode() == 200) { + String returnedClientId = response.jsonPath().getString("client_id"); + assertThat(returnedClientId) + .as("Returned client_id should match registered client") + .isEqualTo(registeredClientId); + } + } + } + + // ================================================================================== + // Scenario 2: OIDC Discovery → JWKS → Token Verification + // Validates that tokens can be verified using keys discovered via OIDC Discovery + // ================================================================================== + + @Nested + @DisplayName("Scenario 2: OIDC Discovery → JWKS → Token Verification") + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class DiscoveryToJwksToTokenVerificationTests { + + private String discoveredJwksUri; + private String discoveredTokenEndpoint; + private String discoveredIssuer; + + @Test + @Order(1) + @DisplayName("Step 1: Discover JWKS URI from OIDC Discovery endpoint") + void discoverJwksUriFromOidcDiscovery() { + Response discoveryResponse = given() + .baseUri(AGENT_USER_IDP_BASE_URI) + .when() + .get(DISCOVERY_ENDPOINT); + + assertThat(discoveryResponse.getStatusCode()) + .as("OIDC Discovery endpoint should return 200") + .isEqualTo(200); + + discoveredJwksUri = discoveryResponse.jsonPath().getString("jwks_uri"); + discoveredTokenEndpoint = discoveryResponse.jsonPath().getString("token_endpoint"); + discoveredIssuer = discoveryResponse.jsonPath().getString("issuer"); + + assertThat(discoveredJwksUri) + .as("Discovery response must contain jwks_uri") + .isNotNull().isNotEmpty(); + assertThat(discoveredTokenEndpoint) + .as("Discovery response must contain token_endpoint") + .isNotNull().isNotEmpty(); + assertThat(discoveredIssuer) + .as("Discovery response must contain issuer") + .isNotNull().isNotEmpty(); + } + + @Test + @Order(2) + @DisplayName("Step 2: Fetch JWKS from discovered URI") + void fetchJwksFromDiscoveredUri() { + assertThat(discoveredJwksUri) + .as("JWKS URI must be discovered first") + .isNotNull(); + + Response jwksResponse = given() + .baseUri(discoveredJwksUri) + .when() + .get(""); + + assertThat(jwksResponse.getStatusCode()) + .as("JWKS endpoint should return 200") + .isEqualTo(200); + + List> keys = jwksResponse.jsonPath().getList("keys"); + assertThat(keys) + .as("JWKS must contain at least one key") + .isNotNull() + .isNotEmpty(); + + // Verify each key has required fields + for (Map key : keys) { + assertThat(key.get("kty")) + .as("Each JWK must have 'kty' field") + .isNotNull(); + assertThat(key.get("kid")) + .as("Each JWK must have 'kid' field") + .isNotNull(); + } + } + + @Test + @Order(3) + @DisplayName("Step 3: Verify discovered issuer matches JWKS source") + void verifyDiscoveredIssuerMatchesJwksSource() { + assertThat(discoveredIssuer) + .as("Issuer must be discovered first") + .isNotNull(); + assertThat(discoveredJwksUri) + .as("JWKS URI must be discovered first") + .isNotNull(); + + // The JWKS URI should be under the same issuer domain + assertThat(discoveredJwksUri) + .as("JWKS URI should be hosted by the same issuer") + .startsWith(discoveredIssuer); + } + + @Test + @Order(4) + @DisplayName("Step 4: Verify JWKS keys can parse into JWK objects") + void verifyJwksKeysCanParseIntoJwkObjects() throws ParseException { + assertThat(discoveredJwksUri) + .as("JWKS URI must be discovered first") + .isNotNull(); + + Response jwksResponse = given() + .baseUri(discoveredJwksUri) + .when() + .get(""); + + String jwksJson = jwksResponse.getBody().asString(); + JWKSet jwkSet = JWKSet.parse(jwksJson); + + assertThat(jwkSet.getKeys()) + .as("Parsed JWK Set must contain keys") + .isNotEmpty(); + + for (JWK jwk : jwkSet.getKeys()) { + assertThat(jwk.getKeyID()) + .as("Each parsed JWK must have a key ID") + .isNotNull(); + assertThat(jwk.getAlgorithm()) + .as("Each parsed JWK should have an algorithm") + .isNotNull(); + } + } + + @Test + @Order(5) + @DisplayName("Step 5: Cross-validate JWKS across multiple IDPs") + void crossValidateJwksAcrossMultipleIdps() { + // Fetch JWKS from Agent User IDP + Response agentUserIdpJwks = given() + .baseUri(AGENT_USER_IDP_BASE_URI) + .when() + .get(JWKS_ENDPOINT); + + assertThat(agentUserIdpJwks.getStatusCode()).isEqualTo(200); + + // Fetch JWKS from AS User IDP + Response asUserIdpJwks = given() + .baseUri(AS_USER_IDP_BASE_URI) + .when() + .get(JWKS_ENDPOINT); + + assertThat(asUserIdpJwks.getStatusCode()).isEqualTo(200); + + // Fetch JWKS from Agent IDP + Response agentIdpJwks = given() + .baseUri(AGENT_IDP_BASE_URI) + .when() + .get(JWKS_ENDPOINT); + + assertThat(agentIdpJwks.getStatusCode()).isEqualTo(200); + + // Verify each IDP has distinct key sets (different kid values) + List agentUserKids = agentUserIdpJwks.jsonPath().getList("keys.kid"); + List asUserKids = asUserIdpJwks.jsonPath().getList("keys.kid"); + List agentKids = agentIdpJwks.jsonPath().getList("keys.kid"); + + assertThat(agentUserKids).as("Agent User IDP must have keys").isNotEmpty(); + assertThat(asUserKids).as("AS User IDP must have keys").isNotEmpty(); + assertThat(agentKids).as("Agent IDP must have keys").isNotEmpty(); + } + } + + // ================================================================================== + // Scenario 3: PAR → Authorization → Token Exchange + // Validates the full flow from PAR through authorization to token exchange + // ================================================================================== + + @Nested + @DisplayName("Scenario 3: PAR → Authorization → Token Exchange (RFC 9126 + RFC 8693)") + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class ParToAuthorizationToTokenExchangeTests { + + private String requestUri; + + @Test + @Order(1) + @DisplayName("Step 1: Submit PAR request and obtain request_uri") + void submitParRequestAndObtainRequestUri() { + Response response = given() + .baseUri(AS_BASE_URI) + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("request", generateParJwt(CLIENT_ID, REDIRECT_URI)) + .formParam("response_type", "code") + .formParam("redirect_uri", REDIRECT_URI) + .formParam("scope", "openid profile") + .when() + .post(PAR_ENDPOINT); + + assertThat(response.getStatusCode()) + .as("PAR request should return 201") + .isEqualTo(201); + + requestUri = response.jsonPath().getString("request_uri"); + assertThat(requestUri) + .as("PAR response must contain request_uri") + .isNotNull() + .startsWith("urn:ietf:params:oauth:request_uri:"); + } + + @Test + @Order(2) + @DisplayName("Step 2: Use request_uri at authorization endpoint") + void useRequestUriAtAuthorizationEndpoint() { + assertThat(requestUri) + .as("request_uri must be obtained from PAR first") + .isNotNull(); + + Response authResponse = given() + .baseUri(AS_BASE_URI) + .redirects().follow(false) + .queryParam("client_id", CLIENT_ID) + .queryParam("request_uri", requestUri) + .when() + .get(AUTHORIZATION_ENDPOINT); + + assertThat(authResponse.getStatusCode()) + .as("Authorization endpoint should accept PAR request_uri") + .isIn(200, 302); + } + + @Test + @Order(3) + @DisplayName("Step 3: Token Exchange endpoint accepts token exchange grant type") + void tokenExchangeEndpointAcceptsTokenExchangeGrantType() { + // Generate a mock subject token (self-signed JWT) + String mockSubjectToken = generateMockSubjectToken(); + + Response response = given() + .baseUri(AS_BASE_URI) + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", mockSubjectToken) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + // Token exchange may succeed (200) or fail with proper error (400/500) + // depending on whether the subject_token is valid + assertThat(response.getStatusCode()) + .as("Token endpoint should handle token exchange request") + .isIn(200, 400, 500); + + // Verify response is JSON + assertThat(response.getContentType()) + .as("Response must be JSON") + .startsWith("application/json"); + } + + @Test + @Order(4) + @DisplayName("Step 4: Verify PAR and Token Exchange use same client credentials") + void verifyParAndTokenExchangeUseSameClientCredentials() { + // PAR request with client credentials + Response parResponse = given() + .baseUri(AS_BASE_URI) + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("request", generateParJwt(CLIENT_ID, REDIRECT_URI)) + .formParam("response_type", "code") + .formParam("redirect_uri", REDIRECT_URI) + .when() + .post(PAR_ENDPOINT); + + assertThat(parResponse.getStatusCode()) + .as("PAR should accept client credentials") + .isEqualTo(201); + + // Token Exchange with same client credentials + Response tokenExchangeResponse = given() + .baseUri(AS_BASE_URI) + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", generateMockSubjectToken()) + .formParam("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN) + .when() + .post(TOKEN_ENDPOINT); + + // Both endpoints should accept the same client credentials + assertThat(tokenExchangeResponse.getStatusCode()) + .as("Token exchange should accept same client credentials as PAR") + .isIn(200, 400, 500); + } + + private String generateMockSubjectToken() { + try { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(AS_BASE_URI) + .subject("test-user") + .audience(AS_BASE_URI) + .issueTime(new Date()) + .expirationTime(new Date(System.currentTimeMillis() + 3600_000)) + .jwtID(UUID.randomUUID().toString()) + .claim("scope", "openid profile") + .build(); + + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(testSigningKey.getKeyID()) + .type(JOSEObjectType.JWT) + .build(); + + SignedJWT signedJWT = new SignedJWT(header, claims); + signedJWT.sign(new RSASSASigner(testSigningKey)); + return signedJWT.serialize(); + } catch (Exception e) { + throw new RuntimeException("Failed to generate mock subject token", e); + } + } + } + + // ================================================================================== + // Scenario 4: WIT/WPT → Token Exchange + // Validates that workload identity tokens can be used in token exchange + // ================================================================================== + + @Nested + @DisplayName("Scenario 4: WIT/WPT → Token Exchange (WIMSE + RFC 8693)") + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class WitWptToTokenExchangeTests { + + private ECKey witSigningKey; + private ECKey wptSigningKey; + private String generatedWit; + private String generatedWpt; + + @Test + @Order(1) + @DisplayName("Step 1: Generate WIT (Workload Identity Token)") + void generateWorkloadIdentityToken() throws Exception { + // Generate EC key pair for WIT signing (IDP key) + witSigningKey = new ECKeyGenerator(com.nimbusds.jose.jwk.Curve.P_256) + .keyID("wit-test-key") + .generate(); + + // Generate a separate key for WPT signing (workload's own key) + wptSigningKey = new ECKeyGenerator(com.nimbusds.jose.jwk.Curve.P_256) + .keyID("wpt-test-key") + .generate(); + + // Build WIT claims with cnf (confirmation) claim containing the WPT public key + JWTClaimsSet witClaims = new JWTClaimsSet.Builder() + .issuer("https://idp.example.com") + .subject("workload-agent-001") + .audience("https://as.example.com") + .issueTime(new Date()) + .expirationTime(new Date(System.currentTimeMillis() + 3600_000)) + .jwtID(UUID.randomUUID().toString()) + .claim("cnf", Map.of("jwk", wptSigningKey.toPublicJWK().toJSONObject())) + .build(); + + JWSHeader witHeader = new JWSHeader.Builder(JWSAlgorithm.ES256) + .keyID(witSigningKey.getKeyID()) + .type(new JOSEObjectType("wit+jwt")) + .build(); + + SignedJWT witJwt = new SignedJWT(witHeader, witClaims); + witJwt.sign(new ECDSASigner(witSigningKey)); + generatedWit = witJwt.serialize(); + + assertThat(generatedWit) + .as("WIT must be a valid JWT") + .isNotNull() + .contains("."); + + // Verify WIT structure + SignedJWT parsedWit = SignedJWT.parse(generatedWit); + assertThat(parsedWit.getHeader().getType().toString()) + .as("WIT header typ must be 'wit+jwt'") + .isEqualTo("wit+jwt"); + assertThat(parsedWit.getJWTClaimsSet().getClaim("cnf")) + .as("WIT must contain cnf claim") + .isNotNull(); + } + + @Test + @Order(2) + @DisplayName("Step 2: Generate WPT (Workload Proof Token) bound to WIT") + void generateWorkloadProofTokenBoundToWit() throws Exception { + assertThat(generatedWit) + .as("WIT must be generated first") + .isNotNull(); + + // Calculate SHA-256 hash of WIT for the wth claim + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] witHash = digest.digest(generatedWit.getBytes(StandardCharsets.US_ASCII)); + String witHashBase64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(witHash); + + // Build WPT claims + JWTClaimsSet wptClaims = new JWTClaimsSet.Builder() + .issuer("workload-agent-001") + .audience(AS_BASE_URI) + .issueTime(new Date()) + .expirationTime(new Date(System.currentTimeMillis() + 300_000)) + .jwtID(UUID.randomUUID().toString()) + .claim("wth", witHashBase64Url) + .build(); + + JWSHeader wptHeader = new JWSHeader.Builder(JWSAlgorithm.ES256) + .keyID(wptSigningKey.getKeyID()) + .type(new JOSEObjectType("wpt+jwt")) + .build(); + + SignedJWT wptJwt = new SignedJWT(wptHeader, wptClaims); + wptJwt.sign(new ECDSASigner(wptSigningKey)); + generatedWpt = wptJwt.serialize(); + + assertThat(generatedWpt) + .as("WPT must be a valid JWT") + .isNotNull() + .contains("."); + + // Verify WPT structure + SignedJWT parsedWpt = SignedJWT.parse(generatedWpt); + assertThat(parsedWpt.getHeader().getType().toString()) + .as("WPT header typ must be 'wpt+jwt'") + .isEqualTo("wpt+jwt"); + assertThat(parsedWpt.getJWTClaimsSet().getStringClaim("wth")) + .as("WPT must contain wth claim") + .isNotNull() + .isEqualTo(witHashBase64Url); + } + + @Test + @Order(3) + @DisplayName("Step 3: Verify WPT is cryptographically bound to WIT") + void verifyWptIsCryptographicallyBoundToWit() throws Exception { + assertThat(generatedWit).as("WIT must be generated").isNotNull(); + assertThat(generatedWpt).as("WPT must be generated").isNotNull(); + + // Parse WIT to extract cnf.jwk (the public key that should sign WPT) + SignedJWT parsedWit = SignedJWT.parse(generatedWit); + @SuppressWarnings("unchecked") + Map cnfClaim = (Map) parsedWit.getJWTClaimsSet().getClaim("cnf"); + @SuppressWarnings("unchecked") + Map jwkMap = (Map) cnfClaim.get("jwk"); + ECKey witCnfKey = ECKey.parse(jwkMap); + + // Parse WPT and verify its signature using the key from WIT's cnf claim + SignedJWT parsedWpt = SignedJWT.parse(generatedWpt); + boolean signatureValid = parsedWpt.verify( + new com.nimbusds.jose.crypto.ECDSAVerifier(witCnfKey)); + + assertThat(signatureValid) + .as("WPT signature must be verifiable with WIT's cnf.jwk public key") + .isTrue(); + + // Verify wth claim matches SHA-256 hash of WIT + String wthClaim = parsedWpt.getJWTClaimsSet().getStringClaim("wth"); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] expectedHash = digest.digest(generatedWit.getBytes(StandardCharsets.US_ASCII)); + String expectedHashBase64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(expectedHash); + + assertThat(wthClaim) + .as("WPT wth claim must match SHA-256 hash of WIT") + .isEqualTo(expectedHashBase64Url); + } + + @Test + @Order(4) + @DisplayName("Step 4: Use WPT as subject_token in Token Exchange request") + void useWptAsSubjectTokenInTokenExchange() { + assertThat(generatedWpt) + .as("WPT must be generated first") + .isNotNull(); + + Response response = given() + .baseUri(AS_BASE_URI) + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", generatedWpt) + .formParam("subject_token_type", TOKEN_TYPE_JWT) + .when() + .post(TOKEN_ENDPOINT); + + // The AS may accept or reject the WPT depending on trust configuration + assertThat(response.getStatusCode()) + .as("Token endpoint should handle WPT-based token exchange") + .isIn(200, 400, 500); + + assertThat(response.getContentType()) + .as("Response must be JSON") + .startsWith("application/json"); + } + + @Test + @Order(5) + @DisplayName("Step 5: Use WIT as actor_token in Token Exchange request") + void useWitAsActorTokenInTokenExchange() { + assertThat(generatedWit) + .as("WIT must be generated first") + .isNotNull(); + assertThat(generatedWpt) + .as("WPT must be generated first") + .isNotNull(); + + Response response = given() + .baseUri(AS_BASE_URI) + .auth().preemptive().basic(CLIENT_ID, CLIENT_SECRET) + .contentType(ContentType.URLENC) + .formParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE) + .formParam("subject_token", generatedWpt) + .formParam("subject_token_type", TOKEN_TYPE_JWT) + .formParam("actor_token", generatedWit) + .formParam("actor_token_type", TOKEN_TYPE_JWT) + .when() + .post(TOKEN_ENDPOINT); + + // The AS may accept or reject depending on trust configuration + assertThat(response.getStatusCode()) + .as("Token endpoint should handle WIT+WPT token exchange") + .isIn(200, 400, 500); + + assertThat(response.getContentType()) + .as("Response must be JSON") + .startsWith("application/json"); + } + } +} diff --git a/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/WimseWorkloadCredsConformanceTest.java b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/WimseWorkloadCredsConformanceTest.java new file mode 100644 index 0000000..110b872 --- /dev/null +++ b/open-agent-auth-integration-tests/src/test/java/com/alibaba/openagentauth/integration/conformance/WimseWorkloadCredsConformanceTest.java @@ -0,0 +1,852 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * 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. + */ +package com.alibaba.openagentauth.integration.conformance; + +import com.alibaba.openagentauth.core.model.jwk.Jwk; +import com.alibaba.openagentauth.core.model.jwk.Jwk.KeyType; +import com.alibaba.openagentauth.core.model.token.WorkloadIdentityToken; +import com.alibaba.openagentauth.core.model.token.WorkloadProofToken; +import com.alibaba.openagentauth.core.protocol.wimse.wpt.WptGenerator; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.ParseException; +import java.util.Base64; +import java.util.Date; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Protocol conformance tests for WIMSE Workload Identity Credentials. + *

+ * This test class validates the framework's implementation of Workload Identity Token (WIT) + * and Workload Proof Token (WPT) against the draft-ietf-wimse-workload-creds specification. + *

+ *

+ * Tested specifications: + *

+ *
    + *
  • WIT format: JOSE header with typ="wit+jwt", required claims (iss, sub, exp, cnf)
  • + *
  • WPT format: JOSE header with typ="wpt+jwt", required claims (aud, exp, jti, wth)
  • + *
  • Cryptographic binding: WPT signed with key from WIT's cnf claim
  • + *
  • Token hash binding: WPT's wth claim contains SHA-256 hash of WIT
  • + *
+ * + * @see draft-ietf-wimse-workload-creds + * @see RFC 7519 - JSON Web Token (JWT) + * @since 1.0 + */ +@ProtocolConformanceTest( + value = "WIMSE Workload Identity Credentials Conformance Tests", + protocol = "WIMSE WIT/WPT", + reference = "draft-ietf-wimse-workload-creds", + requiredServices = {} +) +@DisplayName("WIMSE Workload Identity Credentials Conformance Tests") +class WimseWorkloadCredsConformanceTest { + + private static final String WIT_MEDIA_TYPE = "wit+jwt"; + private static final String WPT_MEDIA_TYPE = "wpt+jwt"; + private static final String ISSUER = "https://idp.example.com"; + private static final String SUBJECT = "workload-agent-001"; + private static final String AUDIENCE = "https://resource.example.com"; + + private RSAKey issuerSigningKey; + private RSAKey workloadKeyPair; + + @BeforeEach + void setUp() throws JOSEException { + issuerSigningKey = new RSAKeyGenerator(2048) + .keyID("issuer-key-001") + .generate(); + + workloadKeyPair = new RSAKeyGenerator(2048) + .keyID("workload-key-001") + .generate(); + } + + @Nested + @DisplayName("WIT Format Conformance (draft-ietf-wimse-workload-creds §3.1)") + class WitFormatTests { + + @Test + @DisplayName("WIT JOSE header typ MUST be 'wit+jwt'") + void witHeaderTypMustBeWitJwt() { + WorkloadIdentityToken.Header header = WorkloadIdentityToken.Header.builder() + .type(WIT_MEDIA_TYPE) + .algorithm("RS256") + .build(); + + assertThat(header.getType()).isEqualTo(WIT_MEDIA_TYPE); + } + + @Test + @DisplayName("WIT JOSE header MUST contain alg parameter") + void witHeaderMustContainAlgParameter() { + WorkloadIdentityToken.Header header = WorkloadIdentityToken.Header.builder() + .type(WIT_MEDIA_TYPE) + .algorithm("RS256") + .build(); + + assertThat(header.getAlgorithm()).isNotNull(); + assertThat(header.getAlgorithm()).isEqualTo("RS256"); + } + + @Test + @DisplayName("WIT alg MUST be an asymmetric digital signature algorithm") + void witAlgMustBeAsymmetricSignatureAlgorithm() { + String[] validAlgorithms = {"RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"}; + + for (String algorithm : validAlgorithms) { + WorkloadIdentityToken.Header header = WorkloadIdentityToken.Header.builder() + .type(WIT_MEDIA_TYPE) + .algorithm(algorithm) + .build(); + + assertThat(header.getAlgorithm()) + .as("Algorithm %s should be accepted", algorithm) + .isEqualTo(algorithm); + } + } + + @Test + @DisplayName("WIT MUST be a valid JWT with three base64url-encoded segments") + void witMustBeValidJwtFormat() throws JOSEException { + String witJwtString = generateSignedWitJwtString(); + + String[] segments = witJwtString.split("\\."); + assertThat(segments).hasSize(3); + + for (String segment : segments) { + assertThat(segment).matches("[A-Za-z0-9_-]+"); + } + } + + @Test + @DisplayName("WIT header MUST be parseable as valid JSON") + void witHeaderMustBeParseableJson() throws JOSEException, ParseException { + String witJwtString = generateSignedWitJwtString(); + + SignedJWT parsedJwt = SignedJWT.parse(witJwtString); + JWSHeader header = parsedJwt.getHeader(); + + assertThat(header).isNotNull(); + assertThat(header.getType().getType()).isEqualTo(WIT_MEDIA_TYPE); + assertThat(header.getAlgorithm()).isEqualTo(JWSAlgorithm.RS256); + } + } + + @Nested + @DisplayName("WIT Required Claims Conformance (draft-ietf-wimse-workload-creds §3.1)") + class WitRequiredClaimsTests { + + @Test + @DisplayName("WIT MUST contain iss (Issuer) claim") + void witMustContainIssClaim() { + WorkloadIdentityToken wit = buildValidWit(); + + assertThat(wit.getIssuer()).isNotNull(); + assertThat(wit.getIssuer()).isEqualTo(ISSUER); + } + + @Test + @DisplayName("WIT MUST contain sub (Subject / Workload Identifier) claim") + void witMustContainSubClaim() { + WorkloadIdentityToken wit = buildValidWit(); + + assertThat(wit.getSubject()).isNotNull(); + assertThat(wit.getSubject()).isEqualTo(SUBJECT); + } + + @Test + @DisplayName("WIT MUST contain exp (Expiration Time) claim") + void witMustContainExpClaim() { + WorkloadIdentityToken wit = buildValidWit(); + + assertThat(wit.getExpirationTime()).isNotNull(); + assertThat(wit.getExpirationTime()).isAfter(new Date()); + } + + @Test + @DisplayName("WIT MUST contain cnf (Confirmation) claim with jwk") + void witMustContainCnfClaimWithJwk() { + WorkloadIdentityToken wit = buildValidWit(); + + assertThat(wit.getConfirmation()).isNotNull(); + assertThat(wit.getJwk()).isNotNull(); + } + + @Test + @DisplayName("WIT MAY contain jti (JWT ID) claim for uniqueness") + void witMayContainJtiClaim() { + WorkloadIdentityToken wit = buildValidWit(); + + assertThat(wit.getJwtId()).isNotNull(); + assertThat(wit.getJwtId()).isNotEmpty(); + } + + @Test + @DisplayName("WIT exp claim MUST represent a future time") + void witExpClaimMustBeFutureTime() { + WorkloadIdentityToken wit = buildValidWit(); + + assertThat(wit.isExpired()).isFalse(); + assertThat(wit.isValid()).isTrue(); + } + + @Test + @DisplayName("Expired WIT MUST be detected as invalid") + void expiredWitMustBeDetectedAsInvalid() { + Date pastExpiration = new Date(System.currentTimeMillis() - 60_000); + + WorkloadIdentityToken.Claims claims = WorkloadIdentityToken.Claims.builder() + .issuer(ISSUER) + .subject(SUBJECT) + .expirationTime(pastExpiration) + .jwtId(UUID.randomUUID().toString()) + .build(); + + WorkloadIdentityToken.Header header = WorkloadIdentityToken.Header.builder() + .type(WIT_MEDIA_TYPE) + .algorithm("RS256") + .build(); + + WorkloadIdentityToken expiredWit = WorkloadIdentityToken.builder() + .header(header) + .claims(claims) + .build(); + + assertThat(expiredWit.isExpired()).isTrue(); + assertThat(expiredWit.isValid()).isFalse(); + } + } + + @Nested + @DisplayName("WIT Builder Validation Tests") + class WitBuilderValidationTests { + + @Test + @DisplayName("WIT builder MUST require header") + void witBuilderMustRequireHeader() { + WorkloadIdentityToken.Claims claims = WorkloadIdentityToken.Claims.builder() + .issuer(ISSUER) + .subject(SUBJECT) + .expirationTime(futureDate()) + .build(); + + assertThatThrownBy(() -> WorkloadIdentityToken.builder() + .claims(claims) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("header"); + } + + @Test + @DisplayName("WIT builder MUST require claims") + void witBuilderMustRequireClaims() { + WorkloadIdentityToken.Header header = WorkloadIdentityToken.Header.builder() + .type(WIT_MEDIA_TYPE) + .algorithm("RS256") + .build(); + + assertThatThrownBy(() -> WorkloadIdentityToken.builder() + .header(header) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("claims"); + } + } + + @Nested + @DisplayName("WPT Format Conformance (draft-ietf-wimse-workload-creds §3.2)") + class WptFormatTests { + + @Test + @DisplayName("WPT JOSE header typ MUST be 'wpt+jwt'") + void wptHeaderTypMustBeWptJwt() { + WorkloadProofToken.Header header = WorkloadProofToken.Header.builder() + .type(WPT_MEDIA_TYPE) + .algorithm("RS256") + .build(); + + assertThat(header.getType()).isEqualTo(WPT_MEDIA_TYPE); + } + + @Test + @DisplayName("WPT JOSE header MUST contain alg parameter") + void wptHeaderMustContainAlgParameter() { + WorkloadProofToken.Header header = WorkloadProofToken.Header.builder() + .type(WPT_MEDIA_TYPE) + .algorithm("RS256") + .build(); + + assertThat(header.getAlgorithm()).isNotNull(); + assertThat(header.getAlgorithm()).isEqualTo("RS256"); + } + + @Test + @DisplayName("WPT MUST be a valid JWT with three base64url-encoded segments") + void wptMustBeValidJwtFormat() throws JOSEException { + String wptJwtString = generateSignedWptJwtString(); + + String[] segments = wptJwtString.split("\\."); + assertThat(segments).hasSize(3); + + for (String segment : segments) { + assertThat(segment).matches("[A-Za-z0-9_-]+"); + } + } + + @Test + @DisplayName("WPT header MUST be parseable as valid JSON") + void wptHeaderMustBeParseableJson() throws JOSEException, ParseException { + String wptJwtString = generateSignedWptJwtString(); + + SignedJWT parsedJwt = SignedJWT.parse(wptJwtString); + JWSHeader header = parsedJwt.getHeader(); + + assertThat(header).isNotNull(); + assertThat(header.getType().getType()).isEqualTo(WPT_MEDIA_TYPE); + assertThat(header.getAlgorithm()).isEqualTo(JWSAlgorithm.RS256); + } + } + + @Nested + @DisplayName("WPT Required Claims Conformance (draft-ietf-wimse-workload-creds §3.2)") + class WptRequiredClaimsTests { + + @Test + @DisplayName("WPT MUST contain aud (Audience) claim") + void wptMustContainAudClaim() { + WorkloadProofToken wpt = buildValidWpt(); + + assertThat(wpt.getAudience()).isNotNull(); + assertThat(wpt.getAudience()).isEqualTo(AUDIENCE); + } + + @Test + @DisplayName("WPT MUST contain exp (Expiration Time) claim") + void wptMustContainExpClaim() { + WorkloadProofToken wpt = buildValidWpt(); + + assertThat(wpt.getExpirationTime()).isNotNull(); + assertThat(wpt.getExpirationTime()).isAfter(new Date()); + } + + @Test + @DisplayName("WPT MUST contain jti (JWT ID) claim") + void wptMustContainJtiClaim() { + WorkloadProofToken wpt = buildValidWpt(); + + assertThat(wpt.getJwtId()).isNotNull(); + assertThat(wpt.getJwtId()).isNotEmpty(); + } + + @Test + @DisplayName("WPT MUST contain wth (Workload Token Hash) claim") + void wptMustContainWthClaim() { + WorkloadProofToken wpt = buildValidWpt(); + + assertThat(wpt.getWorkloadTokenHash()).isNotNull(); + assertThat(wpt.getWorkloadTokenHash()).isNotEmpty(); + } + + @Test + @DisplayName("WPT exp claim MUST represent a future time") + void wptExpClaimMustBeFutureTime() { + WorkloadProofToken wpt = buildValidWpt(); + + assertThat(wpt.isExpired()).isFalse(); + assertThat(wpt.isValid()).isTrue(); + } + + @Test + @DisplayName("Expired WPT MUST be detected as invalid") + void expiredWptMustBeDetectedAsInvalid() { + Date pastExpiration = new Date(System.currentTimeMillis() - 60_000); + + WorkloadProofToken.Claims claims = WorkloadProofToken.Claims.builder() + .audience(AUDIENCE) + .expirationTime(pastExpiration) + .jwtId(UUID.randomUUID().toString()) + .workloadTokenHash("test-wth-hash") + .build(); + + WorkloadProofToken.Header header = WorkloadProofToken.Header.builder() + .type(WPT_MEDIA_TYPE) + .algorithm("RS256") + .build(); + + WorkloadProofToken expiredWpt = WorkloadProofToken.builder() + .header(header) + .claims(claims) + .build(); + + assertThat(expiredWpt.isExpired()).isTrue(); + assertThat(expiredWpt.isValid()).isFalse(); + } + } + + @Nested + @DisplayName("WPT Builder Validation Tests") + class WptBuilderValidationTests { + + @Test + @DisplayName("WPT builder MUST require header") + void wptBuilderMustRequireHeader() { + WorkloadProofToken.Claims claims = WorkloadProofToken.Claims.builder() + .audience(AUDIENCE) + .expirationTime(futureDate()) + .jwtId(UUID.randomUUID().toString()) + .workloadTokenHash("test-wth") + .build(); + + assertThatThrownBy(() -> WorkloadProofToken.builder() + .claims(claims) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("header"); + } + + @Test + @DisplayName("WPT builder MUST require claims") + void wptBuilderMustRequireClaims() { + WorkloadProofToken.Header header = WorkloadProofToken.Header.builder() + .type(WPT_MEDIA_TYPE) + .algorithm("RS256") + .build(); + + assertThatThrownBy(() -> WorkloadProofToken.builder() + .header(header) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("claims"); + } + } + + @Nested + @DisplayName("WPT Optional Token Hash Claims Tests") + class WptOptionalTokenHashClaimsTests { + + @Test + @DisplayName("WPT MAY contain ath (Access Token Hash) claim") + void wptMayContainAthClaim() { + String accessTokenHash = computeSha256Hash("sample-access-token"); + + WorkloadProofToken.Claims claims = WorkloadProofToken.Claims.builder() + .audience(AUDIENCE) + .expirationTime(futureDate()) + .jwtId(UUID.randomUUID().toString()) + .workloadTokenHash("test-wth") + .accessTokenHash(accessTokenHash) + .build(); + + assertThat(claims.getAccessTokenHash()).isNotNull(); + assertThat(claims.getAccessTokenHash()).isEqualTo(accessTokenHash); + } + + @Test + @DisplayName("WPT MAY contain tth (Transaction Token Hash) claim") + void wptMayContainTthClaim() { + String transactionTokenHash = computeSha256Hash("sample-transaction-token"); + + WorkloadProofToken.Claims claims = WorkloadProofToken.Claims.builder() + .audience(AUDIENCE) + .expirationTime(futureDate()) + .jwtId(UUID.randomUUID().toString()) + .workloadTokenHash("test-wth") + .transactionTokenHash(transactionTokenHash) + .build(); + + assertThat(claims.getTransactionTokenHash()).isNotNull(); + assertThat(claims.getTransactionTokenHash()).isEqualTo(transactionTokenHash); + } + + @Test + @DisplayName("WPT MAY contain oth (Other Token Hashes) claim") + void wptMayContainOthClaim() { + Map otherHashes = Map.of( + "custom_token", computeSha256Hash("custom-token-value") + ); + + WorkloadProofToken.Claims claims = WorkloadProofToken.Claims.builder() + .audience(AUDIENCE) + .expirationTime(futureDate()) + .jwtId(UUID.randomUUID().toString()) + .workloadTokenHash("test-wth") + .otherTokenHashes(otherHashes) + .build(); + + assertThat(claims.getOtherTokenHashes()).isNotNull(); + assertThat(claims.getOtherTokenHashes()).containsKey("custom_token"); + } + } + + @Nested + @DisplayName("Cryptographic Binding Tests (draft-ietf-wimse-workload-creds §3.2)") + class CryptographicBindingTests { + + @Test + @DisplayName("WPT wth claim MUST be SHA-256 hash of the WIT JWT string") + void wptWthMustBeSha256HashOfWit() throws JOSEException { + String witJwtString = generateSignedWitJwtString(); + String expectedWth = computeSha256Hash(witJwtString); + + WorkloadProofToken.Claims wptClaims = WorkloadProofToken.Claims.builder() + .audience(AUDIENCE) + .expirationTime(futureDate()) + .jwtId(UUID.randomUUID().toString()) + .workloadTokenHash(expectedWth) + .build(); + + assertThat(wptClaims.getWorkloadTokenHash()).isEqualTo(expectedWth); + } + + @Test + @DisplayName("WPT MUST be signed with the private key corresponding to WIT cnf JWK") + void wptMustBeSignedWithWitCnfKey() throws JOSEException, ParseException { + String wptJwtString = generateSignedWptJwtString(); + + SignedJWT parsedWpt = SignedJWT.parse(wptJwtString); + + boolean verified = parsedWpt.verify( + new com.nimbusds.jose.crypto.RSASSAVerifier(workloadKeyPair.toRSAPublicKey()) + ); + assertThat(verified).isTrue(); + } + + @Test + @DisplayName("WPT signature verification MUST fail with wrong key") + void wptSignatureVerificationMustFailWithWrongKey() throws JOSEException, ParseException { + String wptJwtString = generateSignedWptJwtString(); + + RSAKey wrongKey = new RSAKeyGenerator(2048) + .keyID("wrong-key") + .generate(); + + SignedJWT parsedWpt = SignedJWT.parse(wptJwtString); + + boolean verified = parsedWpt.verify( + new com.nimbusds.jose.crypto.RSASSAVerifier(wrongKey.toRSAPublicKey()) + ); + assertThat(verified).isFalse(); + } + + @Test + @DisplayName("WPT wth MUST change when WIT content changes") + void wptWthMustChangeWhenWitChanges() throws JOSEException { + String witJwtString1 = generateSignedWitJwtString(); + String witJwtString2 = generateSignedWitJwtStringWithSubject("different-workload"); + + String wth1 = computeSha256Hash(witJwtString1); + String wth2 = computeSha256Hash(witJwtString2); + + assertThat(wth1).isNotEqualTo(wth2); + } + } + + @Nested + @DisplayName("Interoperability Tests") + class InteroperabilityTests { + + @Test + @DisplayName("WIT JWT MUST be parseable by Nimbus JOSE+JWT library") + void witJwtMustBeParseableByNimbus() throws JOSEException, ParseException { + String witJwtString = generateSignedWitJwtString(); + + SignedJWT parsedJwt = SignedJWT.parse(witJwtString); + + assertThat(parsedJwt.getHeader().getType().getType()).isEqualTo(WIT_MEDIA_TYPE); + assertThat(parsedJwt.getJWTClaimsSet().getIssuer()).isEqualTo(ISSUER); + assertThat(parsedJwt.getJWTClaimsSet().getSubject()).isEqualTo(SUBJECT); + assertThat(parsedJwt.getJWTClaimsSet().getExpirationTime()).isNotNull(); + } + + @Test + @DisplayName("WPT JWT MUST be parseable by Nimbus JOSE+JWT library") + void wptJwtMustBeParseableByNimbus() throws JOSEException, ParseException { + String wptJwtString = generateSignedWptJwtString(); + + SignedJWT parsedJwt = SignedJWT.parse(wptJwtString); + + assertThat(parsedJwt.getHeader().getType().getType()).isEqualTo(WPT_MEDIA_TYPE); + assertThat(parsedJwt.getJWTClaimsSet().getAudience()).contains(AUDIENCE); + assertThat(parsedJwt.getJWTClaimsSet().getExpirationTime()).isNotNull(); + assertThat(parsedJwt.getJWTClaimsSet().getJWTID()).isNotNull(); + assertThat(parsedJwt.getJWTClaimsSet().getClaim("wth")).isNotNull(); + } + + @Test + @DisplayName("WIT signature MUST be verifiable with issuer's public key via Nimbus") + void witSignatureMustBeVerifiableViaNimbus() throws JOSEException, ParseException { + String witJwtString = generateSignedWitJwtString(); + + SignedJWT parsedJwt = SignedJWT.parse(witJwtString); + + boolean verified = parsedJwt.verify( + new com.nimbusds.jose.crypto.RSASSAVerifier(issuerSigningKey.toRSAPublicKey()) + ); + assertThat(verified).isTrue(); + } + + @Test + @DisplayName("WIT cnf claim MUST contain valid JWK that can be parsed by Nimbus") + void witCnfClaimMustContainValidJwk() throws JOSEException, ParseException { + String witJwtString = generateSignedWitJwtString(); + + SignedJWT parsedJwt = SignedJWT.parse(witJwtString); + Map cnfClaim = parsedJwt.getJWTClaimsSet().getJSONObjectClaim("cnf"); + + assertThat(cnfClaim).isNotNull(); + assertThat(cnfClaim).containsKey("jwk"); + + @SuppressWarnings("unchecked") + Map jwkMap = (Map) cnfClaim.get("jwk"); + assertThat(jwkMap).containsKey("kty"); + assertThat(jwkMap).containsKey("n"); + assertThat(jwkMap).containsKey("e"); + assertThat(jwkMap).doesNotContainKey("d"); + } + } + + @Nested + @DisplayName("WptGenerator Integration Tests") + class WptGeneratorTests { + + @Test + @DisplayName("WptGenerator MUST produce valid WPT from WIT") + void wptGeneratorMustProduceValidWpt() throws JOSEException { + WorkloadIdentityToken wit = buildValidWitWithJwtString(); + WptGenerator wptGenerator = new WptGenerator(); + + WorkloadProofToken wpt = wptGenerator.generateWpt( + wit, + workloadKeyPair, + 300 + ); + + assertThat(wpt).isNotNull(); + assertThat(wpt.getHeader()).isNotNull(); + assertThat(wpt.getHeader().getType()).isEqualTo(WPT_MEDIA_TYPE); + assertThat(wpt.getClaims()).isNotNull(); + assertThat(wpt.getWorkloadTokenHash()).isNotNull(); + assertThat(wpt.getExpirationTime()).isNotNull(); + assertThat(wpt.getJwtId()).isNotNull(); + } + + @Test + @DisplayName("WptGenerator MUST produce WPT as JWT string") + void wptGeneratorMustProduceWptAsJwtString() throws JOSEException { + WorkloadIdentityToken wit = buildValidWitWithJwtString(); + WptGenerator wptGenerator = new WptGenerator(); + + String wptJwtString = wptGenerator.generateWptAsString( + wit, + workloadKeyPair, + 300 + ); + + assertThat(wptJwtString).isNotNull(); + assertThat(wptJwtString.split("\\.")).hasSize(3); + } + + @Test + @DisplayName("WptGenerator WPT wth MUST match SHA-256 hash of WIT JWT string") + void wptGeneratorWthMustMatchWitHash() throws JOSEException { + WorkloadIdentityToken wit = buildValidWitWithJwtString(); + WptGenerator wptGenerator = new WptGenerator(); + + WorkloadProofToken wpt = wptGenerator.generateWpt( + wit, + workloadKeyPair, + 300 + ); + + String expectedWth = computeSha256Hash(wit.getJwtString()); + assertThat(wpt.getWorkloadTokenHash()).isEqualTo(expectedWth); + } + } + + // ========== Helper Methods ========== + + private WorkloadIdentityToken buildValidWit() { + WorkloadIdentityToken.Header header = WorkloadIdentityToken.Header.builder() + .type(WIT_MEDIA_TYPE) + .algorithm("RS256") + .build(); + + // Build JWK from workloadKeyPair for cnf claim + Jwk jwk = Jwk.builder() + .keyType(KeyType.RSA) + .keyId(workloadKeyPair.getKeyID()) + .algorithm("RS256") + .build(); + + WorkloadIdentityToken.Claims.Confirmation confirmation = + WorkloadIdentityToken.Claims.Confirmation.builder() + .jwk(jwk) + .build(); + + WorkloadIdentityToken.Claims claims = WorkloadIdentityToken.Claims.builder() + .issuer(ISSUER) + .subject(SUBJECT) + .expirationTime(futureDate()) + .jwtId(UUID.randomUUID().toString()) + .confirmation(confirmation) + .build(); + + return WorkloadIdentityToken.builder() + .header(header) + .claims(claims) + .build(); + } + + private WorkloadIdentityToken buildValidWitWithJwtString() throws JOSEException { + String jwtString = generateSignedWitJwtString(); + + WorkloadIdentityToken.Header header = WorkloadIdentityToken.Header.builder() + .type(WIT_MEDIA_TYPE) + .algorithm("RS256") + .build(); + + // Build JWK from workloadKeyPair for cnf claim + Jwk jwk = Jwk.builder() + .keyType(KeyType.RSA) + .keyId(workloadKeyPair.getKeyID()) + .algorithm("RS256") + .build(); + + WorkloadIdentityToken.Claims.Confirmation confirmation = + WorkloadIdentityToken.Claims.Confirmation.builder() + .jwk(jwk) + .build(); + + WorkloadIdentityToken.Claims claims = WorkloadIdentityToken.Claims.builder() + .issuer(ISSUER) + .subject(SUBJECT) + .expirationTime(futureDate()) + .jwtId(UUID.randomUUID().toString()) + .confirmation(confirmation) + .build(); + + return WorkloadIdentityToken.builder() + .header(header) + .claims(claims) + .jwtString(jwtString) + .build(); + } + + private WorkloadProofToken buildValidWpt() { + WorkloadProofToken.Header header = WorkloadProofToken.Header.builder() + .type(WPT_MEDIA_TYPE) + .algorithm("RS256") + .build(); + + WorkloadProofToken.Claims claims = WorkloadProofToken.Claims.builder() + .audience(AUDIENCE) + .expirationTime(futureDate()) + .jwtId(UUID.randomUUID().toString()) + .workloadTokenHash(computeSha256Hash("test.wit.jwt.string")) + .build(); + + return WorkloadProofToken.builder() + .header(header) + .claims(claims) + .build(); + } + + private String generateSignedWitJwtString() throws JOSEException { + return generateSignedWitJwtStringWithSubject(SUBJECT); + } + + private String generateSignedWitJwtStringWithSubject(String subject) throws JOSEException { + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .type(new com.nimbusds.jose.JOSEObjectType(WIT_MEDIA_TYPE)) + .keyID(issuerSigningKey.getKeyID()) + .build(); + + Map cnfClaim = Map.of( + "jwk", workloadKeyPair.toPublicJWK().toJSONObject() + ); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .subject(subject) + .expirationTime(futureDate()) + .jwtID(UUID.randomUUID().toString()) + .claim("cnf", cnfClaim) + .build(); + + SignedJWT signedJwt = new SignedJWT(header, claimsSet); + JWSSigner signer = new RSASSASigner(issuerSigningKey); + signedJwt.sign(signer); + + return signedJwt.serialize(); + } + + private String generateSignedWptJwtString() throws JOSEException { + String witJwtString = generateSignedWitJwtString(); + String wth = computeSha256Hash(witJwtString); + + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256) + .type(new com.nimbusds.jose.JOSEObjectType(WPT_MEDIA_TYPE)) + .keyID(workloadKeyPair.getKeyID()) + .build(); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .audience(AUDIENCE) + .expirationTime(futureDate()) + .jwtID(UUID.randomUUID().toString()) + .claim("wth", wth) + .build(); + + SignedJWT signedJwt = new SignedJWT(header, claimsSet); + JWSSigner signer = new RSASSASigner(workloadKeyPair); + signedJwt.sign(signer); + + return signedJwt.serialize(); + } + + private static Date futureDate() { + return new Date(System.currentTimeMillis() + 3_600_000); + } + + private static String computeSha256Hash(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } +}