diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index c280da98..9384015b 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -228,6 +228,21 @@ jobs: sudo ./scripts/enable_userns.sh ./examples/kind/kind-oidc-workload-identity.sh + aws-secrets-manager-bearer: + name: AWS Secrets Manager Bearer Auth E2E + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: 1.25.x + - name: Install crane + run: | + go install github.com/google/go-containerregistry/cmd/crane@latest + - name: Run AWS Secrets Manager bearer auth tests + run: | + ./examples/aws-secrets-manager-bearer-auth.sh + cloud-scale-out: name: s3+dynamodb scale-out runs-on: oracle-vm-16cpu-64gb-x86-64 diff --git a/examples/README-AWS-SECRETS-MANAGER-BEARER-AUTH.md b/examples/README-AWS-SECRETS-MANAGER-BEARER-AUTH.md new file mode 100644 index 00000000..0a88848d --- /dev/null +++ b/examples/README-AWS-SECRETS-MANAGER-BEARER-AUTH.md @@ -0,0 +1,279 @@ +# AWS Secrets Manager Bearer Authentication + +This document describes how to configure Zot to retrieve JWT verification keys from AWS Secrets Manager, enabling dynamic key rotation without restarting the registry. + +## Overview + +AWS Secrets Manager bearer authentication allows Zot to retrieve public keys for JWT verification from AWS Secrets Manager instead of loading them from a static file on disk. This is useful when keys need to be rotated without downtime, or when the same keys are shared across multiple Zot instances. + +Zot periodically refreshes the keys from AWS Secrets Manager, so key rotations are picked up automatically. + +## Benefits + +- **Dynamic Key Rotation**: Rotate public keys in AWS Secrets Manager without restarting Zot +- **Centralized Key Management**: Manage verification keys for multiple Zot instances in a single place +- **Lazy Loading**: Keys are fetched on first authentication, so startup is not blocked by network issues +- **Caching**: Keys are cached locally and refreshed periodically to minimize API calls +- **Multiple Keys**: Support multiple key IDs (`kid`) for seamless key rotation + +## Configuration + +### Basic Configuration + +Add AWS Secrets Manager configuration to your bearer authentication settings: + +```json +{ + "http": { + "auth": { + "bearer": { + "realm": "zot", + "service": "zot-service", + "awsSecretsManager": { + "region": "us-east-1", + "secretName": "zot/jwt-verification-keys" + } + } + } + } +} +``` + +### Configuration Options + +- **`region`** (required): The AWS region where the secret is stored. + - Example: `"us-east-1"` + +- **`secretName`** (required): The name or ARN of the secret in AWS Secrets Manager. + - Example: `"zot/jwt-verification-keys"` + - Example: `"arn:aws:secretsmanager:us-east-1:123456789012:secret:zot/keys-AbCdEf"` + +- **`refreshInterval`** (optional): How often to refresh keys from AWS Secrets Manager. Default: `1m` (1 minute). + - Example: `"5m"` (5 minutes) + - Example: `"30s"` (30 seconds) + +## Secret Format + +The secret stored in AWS Secrets Manager must be a JSON object where each key is a key ID (`kid`) and each value is either a PEM-encoded public key or a JWKS key set with a single key: + +### PEM Format + +```json +{ + "key-id-1": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA...\n-----END PUBLIC KEY-----\n", + "key-id-2": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG...\n-----END PUBLIC KEY-----\n" +} +``` + +### JWKS Format + +Each value can also be a JWKS key set containing a single key: + +```json +{ + "key-id-1": "{\"keys\":[{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"x\":\"...\"}]}" +} +``` + +### Supported Key Types + +- **Ed25519** (EdDSA) - Recommended for new deployments +- **RSA** (RS256, RS384, RS512, PS256, PS384, PS512) +- **ECDSA** (ES256, ES384, ES512) + +## Complete Example + +```json +{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "auth": { + "bearer": { + "realm": "zot", + "service": "zot-service", + "awsSecretsManager": { + "region": "us-east-1", + "secretName": "zot/jwt-verification-keys", + "refreshInterval": "5m" + } + } + }, + "accessControl": { + "repositories": { + "**": { + "policies": [ + { + "users": ["service-account-1"], + "actions": ["read", "create", "update"] + } + ] + } + } + } + }, + "log": { + "level": "info" + } +} +``` + +## JWT Token Requirements + +JWTs presented to Zot must include a `kid` (Key ID) header that matches one of the key IDs in the secret. This is how Zot selects the correct public key for verification. + +### Example JWT Header + +```json +{ + "alg": "EdDSA", + "kid": "key-id-1", + "typ": "JWT" +} +``` + +### Example JWT Payload + +```json +{ + "iss": "https://auth.example.com", + "sub": "service-account-1", + "aud": ["zot"], + "exp": 1705258800, + "iat": 1705255200, + "access": [ + { + "type": "repository", + "name": "my-app", + "actions": ["pull", "push"] + } + ] +} +``` + +## Key Rotation + +To rotate keys without downtime: + +1. **Add the new key** to the secret in AWS Secrets Manager alongside the existing key(s): + ```json + { + "old-key-id": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n", + "new-key-id": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" + } + ``` + +2. **Update your token issuer** to sign new tokens with the new key ID. + +3. **Wait for the refresh interval** to elapse. Zot will automatically pick up the new key. + +4. **Remove the old key** from the secret once all tokens signed with the old key have expired. + +## Compatibility + +### With OIDC Workload Identity + +AWS Secrets Manager bearer authentication can coexist with OIDC workload identity. If both are configured, Zot will try OIDC authentication first, then fall back to traditional bearer token authentication using keys from AWS Secrets Manager: + +```json +{ + "http": { + "auth": { + "bearer": { + "realm": "zot", + "service": "zot-service", + "awsSecretsManager": { + "region": "us-east-1", + "secretName": "zot/jwt-verification-keys" + }, + "oidc": [ + { + "issuer": "https://kubernetes.default.svc.cluster.local", + "audiences": ["zot"] + } + ] + } + } + } +} +``` + +### With Static Certificate + +AWS Secrets Manager and the static `cert` option are mutually exclusive. Zot will refuse to start if both are configured. + +## AWS Authentication + +Zot uses the default AWS credential chain to authenticate with AWS Secrets Manager. This means you can use any of the following: + +- **Environment variables**: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` +- **Shared credentials file**: `~/.aws/credentials` +- **IAM role** (for EC2, ECS, or EKS workloads) +- **Web identity token** (for EKS with IRSA) + +### Required IAM Permissions + +The IAM principal used by Zot needs the following permission: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "secretsmanager:GetSecretValue", + "Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:zot/jwt-verification-keys-*" + } + ] +} +``` + +## Troubleshooting + +### Enable Debug Logging + +Set log level to `debug` to see detailed authentication logs: + +```json +{ + "log": { + "level": "debug" + } +} +``` + +### Common Issues + +1. **"region must be specified"**: The `region` field is required in the configuration. + +2. **"secret name must be specified"**: The `secretName` field is required in the configuration. + +3. **"failed to load AWS configuration"**: Check that AWS credentials are available and valid. + +4. **"failed to retrieve secret"**: Verify the secret exists in the specified region and the IAM principal has `secretsmanager:GetSecretValue` permission. + +5. **"failed to parse secret JSON"**: The secret value must be a valid JSON object mapping key IDs to PEM-encoded public keys. + +6. **"no public key found for kid"**: The JWT's `kid` header does not match any key ID in the secret. Check that the key ID in the JWT matches one of the keys in the secret. + +7. **"token missing 'kid' header"**: JWTs must include a `kid` header when using AWS Secrets Manager keys. + +8. **"cannot configure both cert and AWS Secrets Manager"**: The static `cert` and `awsSecretsManager` options are mutually exclusive. Remove one of them. + +## Security Considerations + +1. **Least Privilege**: Grant only `secretsmanager:GetSecretValue` permission, scoped to the specific secret ARN. + +2. **Secret Encryption**: AWS Secrets Manager encrypts secrets at rest using KMS. Use a customer-managed KMS key for additional control. + +3. **Refresh Interval**: Use a short refresh interval (e.g., 1-5 minutes) to pick up key rotations quickly. The default of 1 minute is appropriate for most use cases. + +4. **Key Types**: Prefer Ed25519 (EdDSA) keys for new deployments. They are faster and produce smaller signatures than RSA. + +5. **TLS**: Always use TLS for all communication to protect tokens in transit. + +6. **Access Control**: Always configure access control policies to limit what authenticated clients can do. diff --git a/examples/aws-secrets-manager-bearer-auth.sh b/examples/aws-secrets-manager-bearer-auth.sh new file mode 100755 index 00000000..361bc116 --- /dev/null +++ b/examples/aws-secrets-manager-bearer-auth.sh @@ -0,0 +1,535 @@ +#!/bin/bash +# AWS Secrets Manager Bearer Authentication E2E Test +# +# This script tests bearer authentication with JWT verification keys +# stored in AWS Secrets Manager. It uses hardcoded Ed25519 JWKS keys +# to sign JWTs and verifies that Zot correctly authenticates requests +# using keys retrieved from AWS Secrets Manager. +# +# The test: +# 1. Starts LocalStack (or uses real AWS) for Secrets Manager +# 2. Creates a secret with Ed25519 public keys +# 3. Builds and starts Zot with AWS Secrets Manager bearer auth +# 4. Tests push/pull operations with crane using pre-signed JWTs +# 5. Verifies authentication fails without a valid token +# +# By default, the script uses LocalStack (a local AWS emulator running in Docker) +# so no real AWS credentials are needed. Use --use-real-aws to test against +# a real AWS account with credentials from ~/.aws/credentials. +# +# Usage: +# ./aws-secrets-manager-bearer-auth.sh [OPTIONS] +# +# Options: +# --use-real-aws Use real AWS Secrets Manager (requires ~/.aws/credentials) +# --skip-build Skip building zot (reuse existing binary) +# --keep-resources Don't clean up resources on exit +# --region REGION AWS region (default: us-east-1) +# --help Show this help message +# +# Prerequisites: +# go, crane, jq, curl +# For LocalStack mode (default): docker +# For real AWS mode: aws CLI with configured credentials + +set -o errexit +set -o pipefail + +# Parse command line arguments +USE_REAL_AWS=false +SKIP_BUILD=false +KEEP_RESOURCES=false +AWS_REGION="us-east-1" + +while [[ $# -gt 0 ]]; do + case $1 in + --use-real-aws) + USE_REAL_AWS=true + shift + ;; + --skip-build) + SKIP_BUILD=true + shift + ;; + --keep-resources) + KEEP_RESOURCES=true + shift + ;; + --region) + AWS_REGION="$2" + shift 2 + ;; + --help) + sed -n '2,/^$/p' "$0" | grep -E "^#" | sed 's/^# *//' + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check prerequisites +check_prerequisites() { + local missing="" + for cmd in go crane jq curl; do + if ! command -v "$cmd" &> /dev/null; then + missing="$missing $cmd" + fi + done + + if [ "$USE_REAL_AWS" = true ]; then + if ! command -v aws &> /dev/null; then + missing="$missing aws" + fi + else + if ! command -v docker &> /dev/null; then + missing="$missing docker" + fi + # Also need aws CLI to talk to LocalStack + if ! command -v aws &> /dev/null; then + missing="$missing aws" + fi + fi + + if [ -n "$missing" ]; then + log_error "Missing required tools:$missing" + exit 1 + fi + + if [ "$USE_REAL_AWS" = true ]; then + # Verify AWS credentials are configured + if ! aws sts get-caller-identity --region "${AWS_REGION}" &>/dev/null; then + log_error "AWS credentials not configured or invalid. Check ~/.aws/credentials" + exit 1 + fi + log_info "AWS credentials verified (real AWS mode)" + else + log_info "Using LocalStack mode (no real AWS credentials needed)" + fi +} + +check_prerequisites + +ROOT_DIR=$(git rev-parse --show-toplevel) +cd "${ROOT_DIR}" + +# Configuration +ZOT_PORT="5000" +ZOT_BINARY="/tmp/zot-asm-test" +ZOT_CONFIG="/tmp/zot-asm-config.json" +ZOT_STORAGE="/tmp/zot-asm-storage" +ZOT_PID_FILE="/tmp/zot-asm-test.pid" +SECRET_NAME="zot/e2e-test-jwt-keys-$(date +%s)" +LOCALSTACK_CONTAINER="localstack-zot-asm-test" +LOCALSTACK_PORT="4566" +BUSYBOX_IMAGE="gcr.io/google-containers/busybox:1.27" + +# AWS CLI flags: when using LocalStack, point the AWS CLI at the local endpoint +# and set dummy credentials (LocalStack doesn't validate them). +if [ "$USE_REAL_AWS" = true ]; then + AWS_CMD_FLAGS="--region ${AWS_REGION}" +else + AWS_CMD_FLAGS="--region ${AWS_REGION} --endpoint-url http://127.0.0.1:${LOCALSTACK_PORT}" + export AWS_ACCESS_KEY_ID="test" + export AWS_SECRET_ACCESS_KEY="test" +fi + +# Ed25519 public JWKS key stored in AWS Secrets Manager for JWT verification. +PUBLIC_JWKS='{"keys":[{"use":"sig","kty":"OKP","kid":"01f0ff96-0286-62c9-9fe0-68c6ac4f48e0","crv":"Ed25519","alg":"EdDSA","x":"3pL95mHbZYNG6-YT_MqXKibGQrXF7WziWk25EcgEJGs"}]}' +KID="01f0ff96-0286-62c9-9fe0-68c6ac4f48e0" + +# Pre-signed long-lived JWTs (exp ~2126) for test use only. Signed with the Ed25519 +# private key corresponding to the public key above (private key not stored in repo). +# JWT with push+pull access to "test-repo" +TOKEN_PUSH_PULL="eyJhbGciOiJFZERTQSIsImtpZCI6IjAxZjBmZjk2LTAyODYtNjJjOS05ZmUwLTY4YzZhYzRmNDhlMCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzZXJ2aWNlLWFjY291bnQtMSIsImV4cCI6NDkyMzY0NzIxMCwiaWF0IjoxNzcwMDQ3MjEwLCJhY2Nlc3MiOlt7InR5cGUiOiJyZXBvc2l0b3J5IiwibmFtZSI6InRlc3QtcmVwbyIsImFjdGlvbnMiOlsicHVsbCIsInB1c2giXX1dfQ.D8CN1Yt5gUV9ZEJHOkJWmEa54Ame5oHyERjH0-_TDkLBa2hjHRq6StJOUCl8wejZ2O_oFGspdlz2X_MVwWXMCQ" +# JWT with pull-only access to "test-repo" +TOKEN_PULL_ONLY="eyJhbGciOiJFZERTQSIsImtpZCI6IjAxZjBmZjk2LTAyODYtNjJjOS05ZmUwLTY4YzZhYzRmNDhlMCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzZXJ2aWNlLWFjY291bnQtMSIsImV4cCI6NDkyMzY0NzIxMiwiaWF0IjoxNzcwMDQ3MjEyLCJhY2Nlc3MiOlt7InR5cGUiOiJyZXBvc2l0b3J5IiwibmFtZSI6InRlc3QtcmVwbyIsImFjdGlvbnMiOlsicHVsbCJdfV19.QqVzME9mO61QBhw1gQhBFleBv76Aju3hUVxv-KWZdyYKwHiXINX6vMW8aKWG81DMam26Y19GeMK7QRz5Sg6rAw" +# JWT with no access claims (authentication only) +TOKEN_NO_ACCESS="eyJhbGciOiJFZERTQSIsImtpZCI6IjAxZjBmZjk2LTAyODYtNjJjOS05ZmUwLTY4YzZhYzRmNDhlMCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzZXJ2aWNlLWFjY291bnQtMSIsImV4cCI6NDkyMzY0NzIxMiwiaWF0IjoxNzcwMDQ3MjEyfQ.t-1eng92zu7W4tfskVMHkynlvojSEnwHlWOCIc2MN234rMeqyPZrD9tFmkKsEWlznJpKIo-wMXY70JQkOCQ8Bg" +# JWT with wrong kid (for rejection test) +TOKEN_WRONG_KID="eyJhbGciOiJFZERTQSIsImtpZCI6Indyb25nLWtpZC1kb2VzLW5vdC1leGlzdCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzZXJ2aWNlLWFjY291bnQtMSIsImV4cCI6NDkyMzY0NzIxMiwiaWF0IjoxNzcwMDQ3MjEyLCJhY2Nlc3MiOlt7InR5cGUiOiJyZXBvc2l0b3J5IiwibmFtZSI6InRlc3QtcmVwbyIsImFjdGlvbnMiOlsicHVsbCJdfV19.4WKgZIQwAxTG3xJsn83_qpq1w_b1WvQR977Hj-1LlTKred8bfrcbJt9Fy7_gzP5ee6UgFs-IitldL6hy6emYAA" + +cleanup() { + if [ "$KEEP_RESOURCES" = true ]; then + log_info "Keeping resources (--keep-resources specified)" + log_info "To clean up manually:" + if [ "$USE_REAL_AWS" = true ]; then + log_info " aws secretsmanager delete-secret --secret-id '${SECRET_NAME}' --force-delete-without-recovery --region '${AWS_REGION}'" + else + log_info " docker rm -f ${LOCALSTACK_CONTAINER}" + fi + log_info " kill \$(cat ${ZOT_PID_FILE}) 2>/dev/null" + log_info " rm -rf ${ZOT_STORAGE} ${ZOT_CONFIG}" + return + fi + + log_info "Cleaning up..." + + # Stop zot + if [ -f "${ZOT_PID_FILE}" ]; then + kill "$(cat "${ZOT_PID_FILE}")" 2>/dev/null || true + rm -f "${ZOT_PID_FILE}" + fi + + if [ "$USE_REAL_AWS" = true ]; then + # Delete the secret from AWS Secrets Manager + aws secretsmanager delete-secret \ + --secret-id "${SECRET_NAME}" \ + --force-delete-without-recovery \ + --region "${AWS_REGION}" 2>/dev/null || true + else + # Stop LocalStack + docker rm -f "${LOCALSTACK_CONTAINER}" 2>/dev/null || true + fi + + # Clean up local files + rm -rf "${ZOT_STORAGE}" "${ZOT_CONFIG}" /tmp/zot-asm-docker 2>/dev/null || true +} + +trap cleanup EXIT + +# ============================================================================= +# SETUP +# ============================================================================= + +# Step 1: Start LocalStack if not using real AWS +if [ "$USE_REAL_AWS" = false ]; then + log_info "Starting LocalStack..." + docker rm -f "${LOCALSTACK_CONTAINER}" 2>/dev/null || true + docker run -d \ + --name "${LOCALSTACK_CONTAINER}" \ + -p "${LOCALSTACK_PORT}:4566" \ + ghcr.io/project-zot/ci-images/localstack:3.3.0 + + # Wait for LocalStack to be ready + log_info "Waiting for LocalStack to be ready..." + for i in {1..30}; do + if curl -s "http://127.0.0.1:${LOCALSTACK_PORT}/_localstack/health" 2>/dev/null | grep -q '"secretsmanager"'; then + log_info "LocalStack is ready" + break + fi + if [ "$i" -eq 30 ]; then + log_error "LocalStack failed to start" + docker logs "${LOCALSTACK_CONTAINER}" 2>&1 | tail -20 + exit 1 + fi + sleep 1 + done +fi + +# Step 2: Create the secret in AWS Secrets Manager +log_info "Creating secret '${SECRET_NAME}' in Secrets Manager (${AWS_REGION})..." + +# The secret format is a JSON object: {"kid": "JWKS-or-PEM-string"} +# We store the public key in JWKS format. +SECRET_VALUE=$(jq -n --arg kid "$KID" --arg key "$PUBLIC_JWKS" '{($kid): $key}') + +# shellcheck disable=SC2086 +aws secretsmanager create-secret \ + --name "${SECRET_NAME}" \ + --secret-string "${SECRET_VALUE}" \ + ${AWS_CMD_FLAGS} \ + --output json | jq . + +log_info "Secret created successfully" + +# Verify the secret can be retrieved +log_info "Verifying secret retrieval..." +# shellcheck disable=SC2086 +RETRIEVED=$(aws secretsmanager get-secret-value \ + --secret-id "${SECRET_NAME}" \ + ${AWS_CMD_FLAGS} \ + --query 'SecretString' \ + --output text) + +if [ "$(echo "$RETRIEVED" | jq -r ".[\"$KID\"]")" = "$PUBLIC_JWKS" ]; then + log_info "Secret verification passed" +else + log_error "Secret verification failed" + log_error "Expected: $PUBLIC_JWKS" + log_error "Got: $(echo "$RETRIEVED" | jq -r ".[\"$KID\"]")" + exit 1 +fi + +# Step 3: Build zot +if [ "$SKIP_BUILD" = true ] && [ -f "${ZOT_BINARY}" ]; then + log_info "Skipping build (--skip-build specified, using existing binary)" +else + log_info "Building zot..." + go build -o "${ZOT_BINARY}" ./cmd/zot + log_info "Zot built: ${ZOT_BINARY}" +fi + +# Step 4: Create zot configuration +log_info "Creating zot configuration..." +rm -rf "${ZOT_STORAGE}" +mkdir -p "${ZOT_STORAGE}" + +cat > "${ZOT_CONFIG}" < "${ZOT_PID_FILE}" +log_info "Zot started with PID ${ZOT_PID}" + +# Wait for zot to be ready +log_info "Waiting for zot to be ready..." +for i in {1..30}; do + HTTP_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${ZOT_PORT}/v2/" 2>/dev/null || echo "000") + if [ "$HTTP_RESPONSE" = "401" ] || [ "$HTTP_RESPONSE" = "200" ]; then + log_info "Zot is responding (HTTP $HTTP_RESPONSE)" + break + fi + if [ "$i" -eq 30 ]; then + log_error "Zot failed to start (last HTTP response: $HTTP_RESPONSE)" + exit 1 + fi + sleep 1 +done + +# Helper: configure crane docker config with a given token +setup_crane_docker_config() { + local token="$1" + mkdir -p /tmp/zot-asm-docker + cat > /tmp/zot-asm-docker/config.json <&1) || true + +if echo "$PUSH_OUTPUT" | grep -qiE "error|UNAUTHORIZED|unauthorized|401|403"; then + log_error "TEST 4 FAILED: crane copy failed" + log_error "Output: $PUSH_OUTPUT" + exit 1 +else + log_info "TEST 4 PASSED: crane copy succeeded with push+pull token" + log_info "Output: $(echo "$PUSH_OUTPUT" | tail -3)" +fi + +# TEST 5: List tags using crane WITH pull token +log_info "TEST 5: Listing tags using crane with pull-only token..." +setup_crane_docker_config "$TOKEN_PULL_ONLY" + +TAGS_OUTPUT=$(DOCKER_CONFIG=/tmp/zot-asm-docker crane ls --insecure \ + "${REGISTRY}/test-repo" 2>&1) || true + +if echo "$TAGS_OUTPUT" | grep -q "v1"; then + log_info "TEST 5 PASSED: crane ls succeeded and found tag 'v1'" + log_info "Tags: $TAGS_OUTPUT" +else + log_error "TEST 5 FAILED: Expected to find tag 'v1'" + log_error "Output: $TAGS_OUTPUT" + exit 1 +fi + +# TEST 6: Pull manifest using crane WITH pull token +log_info "TEST 6: Pulling manifest using crane with pull-only token..." +setup_crane_docker_config "$TOKEN_PULL_ONLY" + +MANIFEST_OUTPUT=$(DOCKER_CONFIG=/tmp/zot-asm-docker crane manifest --insecure \ + "${REGISTRY}/test-repo:v1" 2>&1) || true + +if echo "$MANIFEST_OUTPUT" | grep -qiE "schemaVersion|mediaType|manifests"; then + log_info "TEST 6 PASSED: crane manifest succeeded with pull token" + log_info "Manifest preview: $(echo "$MANIFEST_OUTPUT" | head -5)" +else + log_error "TEST 6 FAILED: crane manifest failed with valid pull token" + log_error "Output: $MANIFEST_OUTPUT" + exit 1 +fi + +# TEST 7: Push fails with pull-only token +log_info "TEST 7: Verifying push fails with pull-only token..." +setup_crane_docker_config "$TOKEN_PULL_ONLY" + +PUSH_FAIL_OUTPUT=$(DOCKER_CONFIG=/tmp/zot-asm-docker crane copy --insecure --platform linux/amd64 \ + "${BUSYBOX_IMAGE}" "${REGISTRY}/test-repo:v2" 2>&1 || true) + +if echo "$PUSH_FAIL_OUTPUT" | grep -qiE "401|unauthorized|UNAUTHORIZED"; then + log_info "TEST 7 PASSED: Push correctly rejected with pull-only token" + log_info "Output: $(echo "$PUSH_FAIL_OUTPUT" | tail -2)" +else + log_error "TEST 7 FAILED: Expected 401 for push with pull-only token" + log_error "Output: $PUSH_FAIL_OUTPUT" + exit 1 +fi + +# TEST 8: crane operations fail WITHOUT any token +log_info "TEST 8: Verifying crane operations fail without token..." +remove_crane_auth + +NOTOK_OUTPUT=$(DOCKER_CONFIG=/tmp/zot-asm-docker crane ls --insecure \ + "${REGISTRY}/test-repo" 2>&1 || true) + +if echo "$NOTOK_OUTPUT" | grep -qiE "401|unauthorized|UNAUTHORIZED|error|Error"; then + log_info "TEST 8 PASSED: crane ls failed without token" + log_info "Output: $(echo "$NOTOK_OUTPUT" | tail -2)" +else + log_error "TEST 8 FAILED: Expected error for unauthenticated request" + log_error "Output: $NOTOK_OUTPUT" + exit 1 +fi + +# TEST 9: Token with wrong kid is rejected +log_info "TEST 9: Verifying token with wrong kid is rejected..." + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${TOKEN_WRONG_KID}" \ + "http://${REGISTRY}/v2/") + +if [ "$HTTP_CODE" = "401" ]; then + log_info "TEST 9 PASSED: Wrong kid correctly rejected (HTTP $HTTP_CODE)" +else + log_error "TEST 9 FAILED: Expected 401 for wrong kid, got HTTP $HTTP_CODE" + exit 1 +fi + +# TEST 10: Token accessing unauthorized repository is rejected +log_info "TEST 10: Verifying access to unauthorized repository fails..." + +# The push+pull token only grants access to "test-repo" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${TOKEN_PUSH_PULL}" \ + "http://${REGISTRY}/v2/other-repo/tags/list") + +if [ "$HTTP_CODE" = "401" ]; then + log_info "TEST 10 PASSED: Access to unauthorized repo correctly rejected (HTTP $HTTP_CODE)" +else + log_error "TEST 10 FAILED: Expected 401 for unauthorized repo, got HTTP $HTTP_CODE" + exit 1 +fi + +# ============================================================================= +# SUMMARY +# ============================================================================= + +log_info "==========================================" +log_info "All AWS Secrets Manager Bearer Auth tests PASSED!" +log_info "==========================================" +log_info "" +log_info "Options:" +log_info " --use-real-aws Use real AWS (default: LocalStack)" +log_info " --skip-build Skip building zot" +log_info " --keep-resources Keep resources after exit" +log_info " --region REGION AWS region (default: us-east-1)" diff --git a/pkg/api/authn.go b/pkg/api/authn.go index ef9ec65a..c382f56c 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -7,6 +7,7 @@ import ( "crypto/x509" "encoding/base64" "encoding/hex" + "encoding/json" "encoding/pem" "errors" "fmt" @@ -19,7 +20,9 @@ import ( "strings" "time" + "github.com/go-jose/go-jose/v4" guuid "github.com/gofrs/uuid" + "github.com/golang-jwt/jwt/v5" "github.com/google/go-github/v62/github" "github.com/google/uuid" "github.com/gorilla/mux" @@ -485,10 +488,7 @@ func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc { // Get auth config safely authConfig := ctlr.Config.CopyAuthConfig() - // Initialize authorizers based on configuration - var traditionalAuthorizer *BearerAuthorizer - - var oidcAuthorizer *OIDCBearerAuthorizer + var traditionalAuthorizerKeyFunc BearerAuthorizerKeyFunc // Traditional bearer auth with public key/certificate if authConfig.Bearer.Cert != "" { @@ -499,14 +499,33 @@ func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc { ctlr.Log.Panic().Err(err).Msg("failed to load public key for bearer authentication") } + traditionalAuthorizerKeyFunc = func(_ context.Context, token *jwt.Token) (any, error) { + return publicKey, nil + } + } + + // Traditional bearer auth with AWS Secrets Manager + if authConfig.Bearer.AWSSecretsManager != nil { + asmAuthz, err := NewAWSSecretsManager( + authConfig.Bearer.AWSSecretsManager, AWSSecretsManagerProviderImplementation{}, ctlr.Log) + if err != nil { + ctlr.Log.Panic().Err(err).Msg("failed to create AWS Secrets Manager key function for bearer authentication") + } + traditionalAuthorizerKeyFunc = asmAuthz.GetPublicKey + } + + // Initialize authorizers based on configuration + var traditionalAuthorizer *BearerAuthorizer + if traditionalAuthorizerKeyFunc != nil { traditionalAuthorizer = NewBearerAuthorizer( authConfig.Bearer.Realm, authConfig.Bearer.Service, - publicKey, + traditionalAuthorizerKeyFunc, ) } // OIDC bearer auth for workload identity + var oidcAuthorizer *OIDCBearerAuthorizer if len(authConfig.Bearer.OIDC) > 0 { var err error oidcAuthorizer, err = NewOIDCBearerAuthorizer(authConfig.Bearer.OIDC, ctlr.Log) @@ -608,7 +627,7 @@ func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc { // Fall back to traditional bearer token auth if OIDC didn't succeed if traditionalAuthorizer != nil { - err := traditionalAuthorizer.Authorize(header, requestedAccess) + err := traditionalAuthorizer.Authorize(request.Context(), header, requestedAccess) if err != nil { var challenge *AuthChallengeError if errors.As(err, &challenge) { @@ -1159,6 +1178,19 @@ func loadPublicKeyFromFile(path string) (crypto.PublicKey, error) { return nil, fmt.Errorf("%w: %w, path %s", zerr.ErrCouldNotLoadPublicKey, err, path) } + return loadPublicKeyFromBytes(raw) +} + +func loadPublicKeyFromBytes(raw []byte) (crypto.PublicKey, error) { + var keySet jose.JSONWebKeySet + if err := json.Unmarshal(raw, &keySet); err == nil { + if len(keySet.Keys) != 1 { + return nil, fmt.Errorf("%w: expected 1 key in JWKS, found %d", zerr.ErrCouldNotLoadPublicKey, len(keySet.Keys)) + } + + return keySet.Keys[0].Key, nil + } + block, _ := pem.Decode(raw) if block == nil { return nil, fmt.Errorf("%w: no valid PEM data found", zerr.ErrCouldNotLoadPublicKey) diff --git a/pkg/api/bearer.go b/pkg/api/bearer.go index 7b4cd00c..bcf1bf15 100644 --- a/pkg/api/bearer.go +++ b/pkg/api/bearer.go @@ -1,7 +1,7 @@ package api import ( - "crypto" + "context" "fmt" "regexp" "slices" @@ -72,21 +72,23 @@ func (c AuthChallengeError) Header() string { type BearerAuthorizer struct { realm string service string - key crypto.PublicKey + keyFunc BearerAuthorizerKeyFunc } -func NewBearerAuthorizer(realm string, service string, key crypto.PublicKey) *BearerAuthorizer { +type BearerAuthorizerKeyFunc func(context.Context, *jwt.Token) (any, error) + +func NewBearerAuthorizer(realm string, service string, keyFunc BearerAuthorizerKeyFunc) *BearerAuthorizer { return &BearerAuthorizer{ realm: realm, service: service, - key: key, + keyFunc: keyFunc, } } // Authorize verifies whether the bearer token in the given Authorization header is valid, and whether it has sufficient // scope for the requested resource action. If an authorization error occurs (e.g. no token is given or the token has // insufficient scope), an AuthChallengeError is returned as the error. -func (a *BearerAuthorizer) Authorize(header string, requested *ResourceAction) error { +func (a *BearerAuthorizer) Authorize(ctx context.Context, header string, requested *ResourceAction) error { challenge := &AuthChallengeError{ realm: a.realm, service: a.service, @@ -103,7 +105,7 @@ func (a *BearerAuthorizer) Authorize(header string, requested *ResourceAction) e signedString := bearerTokenMatch.ReplaceAllString(header, "$1") token, err := jwt.ParseWithClaims(signedString, &ClaimsWithAccess{}, func(token *jwt.Token) (any, error) { - return a.key, nil + return a.keyFunc(ctx, token) }, jwt.WithValidMethods(a.allowedSigningAlgorithms()), jwt.WithIssuedAt()) if err != nil { return fmt.Errorf("%w: %w", zerr.ErrInvalidBearerToken, err) diff --git a/pkg/api/bearer_aws_secrets_manager.go b/pkg/api/bearer_aws_secrets_manager.go new file mode 100644 index 00000000..c0398e9a --- /dev/null +++ b/pkg/api/bearer_aws_secrets_manager.go @@ -0,0 +1,174 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/golang-jwt/jwt/v5" + + zerr "zotregistry.dev/zot/v2/errors" + "zotregistry.dev/zot/v2/pkg/api/config" + "zotregistry.dev/zot/v2/pkg/log" +) + +// defaultAWSSecretsManagerRefreshInterval defines the target interval for refreshing the public keys. +// With a 1 minute interval, repeated calls will generally reuse cached keys and only trigger +// a refresh roughly once per minute, but this is best-effort and not a strict upper bound. +const defaultAWSSecretsManagerRefreshInterval = 1 * time.Minute + +// AWSSecretsManagerAuthorizer retrieves public keys from AWS Secrets Manager. +type AWSSecretsManagerAuthorizer struct { + client AWSSecretsManagerClient + secretName string + refreshInterval time.Duration + + // The keys are loaded lazily to avoid network calls during initialization. + // We really don't want to block startup if AWS Secrets Manager is temporarily + // unreachable. Also, we periodically refresh the keys to pick up any changes. + keys map[string]any + keysMu sync.RWMutex + keysDeadline time.Time +} + +// AWSSecretsManagerClient defines an interface for retrieving +// public keys from AWS Secrets Manager. +type AWSSecretsManagerClient interface { + GetSecretValue( + ctx context.Context, + params *secretsmanager.GetSecretValueInput, + optFns ...func(*secretsmanager.Options), + ) (*secretsmanager.GetSecretValueOutput, error) +} + +// AWSSecretsManagerProvider abstracts the functions from the AWS SDK +// needed to create AWS Secrets Manager clients. +type AWSSecretsManagerProvider interface { + LoadDefaultConfig(context.Context, ...func(*awsconfig.LoadOptions) error) (aws.Config, error) + NewFromConfig(aws.Config) AWSSecretsManagerClient +} + +// AWSSecretsManagerProviderImplementation is the production implementation +// for creating AWS Secrets Manager clients. +type AWSSecretsManagerProviderImplementation struct{} + +// LoadDefaultConfig is the production implementation for loading +// AWS configuration. +func (AWSSecretsManagerProviderImplementation) LoadDefaultConfig( + ctx context.Context, + optFns ...func(*awsconfig.LoadOptions) error, +) (aws.Config, error) { + return awsconfig.LoadDefaultConfig(ctx, optFns...) +} + +// NewFromConfig is the production implementation for creating +// AWS Secrets Manager clients. +func (AWSSecretsManagerProviderImplementation) NewFromConfig(cfg aws.Config) AWSSecretsManagerClient { + return secretsmanager.NewFromConfig(cfg) +} + +// NewAWSSecretsManager creates a AWSSecretsManagerAuthorizer that retrieves +// public keys from AWS Secrets Manager based on the provided configuration. +func NewAWSSecretsManager( + conf *config.AWSSecretsManagerConfig, + impl AWSSecretsManagerProvider, + logger log.Logger, +) (*AWSSecretsManagerAuthorizer, error) { + // Apply default refresh interval if not specified. + if conf.RefreshInterval == 0 { + conf.RefreshInterval = defaultAWSSecretsManagerRefreshInterval + } + + // Build AWS Secrets Manager client. + awsConf, err := impl.LoadDefaultConfig(context.Background(), awsconfig.WithRegion(conf.Region)) + if err != nil { + return nil, fmt.Errorf("%w: failed to load AWS configuration: %w", zerr.ErrBadConfig, err) + } + + logger. + Info(). + Str("region", conf.Region). + Str("secretName", conf.SecretName). + Dur("refreshInterval", conf.RefreshInterval). + Msg("the AWS Secrets Manager JWT verification was enabled") + + return &AWSSecretsManagerAuthorizer{ + client: impl.NewFromConfig(awsConf), + secretName: conf.SecretName, + refreshInterval: conf.RefreshInterval, + }, nil +} + +// GetPublicKey retrieves the public key matching the JWT header claim `kid`, +// refreshing all the keys if the refresh interval has elapsed. +func (a *AWSSecretsManagerAuthorizer) GetPublicKey(ctx context.Context, token *jwt.Token) (any, error) { + keys, err := a.GetPublicKeys(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get public keys from AWS Secrets Manager: %w", err) + } + + kid, ok := token.Header["kid"] + if !ok { + return nil, fmt.Errorf("%w: token missing 'kid' header", zerr.ErrInvalidBearerToken) + } + + keyID, ok := kid.(string) + if !ok { + return nil, fmt.Errorf("%w: token 'kid' header is not a string", zerr.ErrInvalidBearerToken) + } + + pubKey, ok := keys[keyID] + if !ok { + return nil, fmt.Errorf("%w: no public key found for kid %s", zerr.ErrInvalidBearerToken, keyID) + } + + return pubKey, nil +} + +// GetPublicKeys retrieves the public keys from AWS Secrets Manager. +func (a *AWSSecretsManagerAuthorizer) GetPublicKeys(ctx context.Context) (map[string]any, error) { + // If the keys are still fresh, return them. + a.keysMu.RLock() + keys, deadline := a.keys, a.keysDeadline + a.keysMu.RUnlock() + if len(keys) > 0 && time.Now().Before(deadline) { + return keys, nil + } + + // Time to refresh the keys. + resp, err := a.client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(a.secretName), + }) + if err != nil { + return nil, fmt.Errorf("failed to retrieve secret from AWS Secrets Manager: %w", err) + } + + // Parse the secret as a map of key ID to public key. + var rawKeys map[string]string + if err := json.Unmarshal([]byte(*resp.SecretString), &rawKeys); err != nil { + return nil, fmt.Errorf("failed to parse secret JSON: %w", err) + } + + // Parse the public keys. + keys = make(map[string]any, len(rawKeys)) + for kid, rawKey := range rawKeys { + pubKey, err := loadPublicKeyFromBytes([]byte(rawKey)) + if err != nil { + return nil, fmt.Errorf("failed to load public key for kid %s: %w", kid, err) + } + keys[kid] = pubKey + } + + // Update the cached keys. + a.keysMu.Lock() + a.keys = keys + a.keysDeadline = time.Now().Add(a.refreshInterval) + a.keysMu.Unlock() + + return keys, nil +} diff --git a/pkg/api/bearer_aws_secrets_manager_test.go b/pkg/api/bearer_aws_secrets_manager_test.go new file mode 100644 index 00000000..b07ff094 --- /dev/null +++ b/pkg/api/bearer_aws_secrets_manager_test.go @@ -0,0 +1,481 @@ +package api_test + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/go-jose/go-jose/v4" + "github.com/golang-jwt/jwt/v5" + . "github.com/smartystreets/goconvey/convey" + + zerr "zotregistry.dev/zot/v2/errors" + "zotregistry.dev/zot/v2/pkg/api" + apiconfig "zotregistry.dev/zot/v2/pkg/api/config" + "zotregistry.dev/zot/v2/pkg/log" +) + +var ( + errAWSConnection = errors.New("aws connection error") + errAWSConfig = errors.New("aws config error") +) + +// mockSecretsManager implements api.AWSSecretsManagerClient for testing. +type mockSecretsManager struct { + secretString string + err error + callCount int +} + +func (m *mockSecretsManager) GetSecretValue( + _ context.Context, + _ *secretsmanager.GetSecretValueInput, + _ ...func(*secretsmanager.Options), +) (*secretsmanager.GetSecretValueOutput, error) { + m.callCount++ + + if m.err != nil { + return nil, m.err + } + + return &secretsmanager.GetSecretValueOutput{ + SecretString: aws.String(m.secretString), + }, nil +} + +// mockAWSImplementation implements api.AWSSecretsManagerProvider for testing. +type mockAWSImplementation struct { + client api.AWSSecretsManagerClient + loadErr error +} + +func (m *mockAWSImplementation) LoadDefaultConfig( + _ context.Context, + _ ...func(*awsconfig.LoadOptions) error, +) (aws.Config, error) { + if m.loadErr != nil { + return aws.Config{}, m.loadErr + } + + return aws.Config{}, nil +} + +func (m *mockAWSImplementation) NewFromConfig(_ aws.Config) api.AWSSecretsManagerClient { + return m.client +} + +// ed25519KeyToJWKS converts an ed25519.PublicKey to a single-key JWKS JSON string. +func ed25519KeyToJWKS(pub ed25519.PublicKey, kid string) string { + jwk := jose.JSONWebKey{ + Key: pub, + KeyID: kid, + Algorithm: "EdDSA", + Use: "sig", + } + + keySet := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{jwk}} + + data, err := json.Marshal(keySet) + if err != nil { + panic(err) + } + + return string(data) +} + +// ed25519KeyToPEM converts an ed25519.PublicKey to PEM-encoded PKIX format. +func ed25519KeyToPEM(pub ed25519.PublicKey) string { + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + panic(err) + } + + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})) +} + +func TestNewAWSSecretsManagerAuthorizerValidation(t *testing.T) { + Convey("Test AWS Secrets Manager config validation", t, func() { + Convey("Zero refresh interval gets default", func() { + mock := &mockSecretsManager{} + conf := &apiconfig.AWSSecretsManagerConfig{ + Region: "us-east-1", + SecretName: "my-secret", + RefreshInterval: 0, + } + _, err := api.NewAWSSecretsManager(conf, &mockAWSImplementation{client: mock}, log.NewLogger("error", "")) + So(err, ShouldBeNil) + // The default was applied (conf.RefreshInterval is mutated). + So(conf.RefreshInterval, ShouldEqual, time.Minute) + }) + + Convey("LoadDefaultConfig error is propagated", func() { + impl := &mockAWSImplementation{loadErr: errAWSConfig} + conf := &apiconfig.AWSSecretsManagerConfig{ + Region: "us-east-1", + SecretName: "my-secret", + RefreshInterval: time.Hour, + } + _, err := api.NewAWSSecretsManager(conf, impl, log.NewLogger("error", "")) + So(err, ShouldNotBeNil) + So(err, ShouldWrap, zerr.ErrBadConfig) + So(err, ShouldWrap, errAWSConfig) + }) + }) +} + +func TestAWSSecretsManagerGetPublicKeys(t *testing.T) { + Convey("Test GetPublicKeys with mock secrets manager", t, func() { + pubKey, _, err := ed25519.GenerateKey(rand.Reader) + So(err, ShouldBeNil) + + secretJSON, err := json.Marshal(map[string]string{ + "key-1": ed25519KeyToPEM(pubKey), + }) + So(err, ShouldBeNil) + + mock := &mockSecretsManager{secretString: string(secretJSON)} + conf := &apiconfig.AWSSecretsManagerConfig{ + Region: "us-east-1", + SecretName: "test-secret", + RefreshInterval: time.Hour, + } + + authz, err := api.NewAWSSecretsManager(conf, &mockAWSImplementation{client: mock}, log.NewLogger("error", "")) + So(err, ShouldBeNil) + + Convey("Keys are fetched on first call", func() { + keys, err := authz.GetPublicKeys(context.Background()) + So(err, ShouldBeNil) + So(keys, ShouldContainKey, "key-1") + So(mock.callCount, ShouldEqual, 1) + }) + + Convey("Keys are cached within refresh interval", func() { + keys1, err := authz.GetPublicKeys(context.Background()) + So(err, ShouldBeNil) + So(keys1, ShouldContainKey, "key-1") + + keys2, err := authz.GetPublicKeys(context.Background()) + So(err, ShouldBeNil) + So(keys2, ShouldContainKey, "key-1") + + // Only one call to the mock — second call used the cache. + So(mock.callCount, ShouldEqual, 1) + }) + + Convey("AWS error is propagated", func() { + failMock := &mockSecretsManager{err: errAWSConnection} + failConf := &apiconfig.AWSSecretsManagerConfig{ + Region: "us-east-1", + SecretName: "test-secret", + RefreshInterval: time.Hour, + } + + failAuthz, err := api.NewAWSSecretsManager( + failConf, &mockAWSImplementation{client: failMock}, log.NewLogger("error", "")) + So(err, ShouldBeNil) + + _, err = failAuthz.GetPublicKeys(context.Background()) + So(err, ShouldNotBeNil) + So(err, ShouldWrap, errAWSConnection) + }) + + Convey("Invalid JSON secret is rejected", func() { + badMock := &mockSecretsManager{secretString: "not-json"} + badConf := &apiconfig.AWSSecretsManagerConfig{ + Region: "us-east-1", + SecretName: "test-secret", + RefreshInterval: time.Hour, + } + + badAuthz, err := api.NewAWSSecretsManager( + badConf, &mockAWSImplementation{client: badMock}, log.NewLogger("error", "")) + So(err, ShouldBeNil) + + _, err = badAuthz.GetPublicKeys(context.Background()) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "failed to parse secret JSON") + }) + + Convey("Invalid PEM key in secret is rejected", func() { + badKeyJSON, err := json.Marshal(map[string]string{ + "bad-key": "not-a-pem-key", + }) + So(err, ShouldBeNil) + + badMock := &mockSecretsManager{secretString: string(badKeyJSON)} + badConf := &apiconfig.AWSSecretsManagerConfig{ + Region: "us-east-1", + SecretName: "test-secret", + RefreshInterval: time.Hour, + } + + badAuthz, err := api.NewAWSSecretsManager( + badConf, &mockAWSImplementation{client: badMock}, log.NewLogger("error", "")) + So(err, ShouldBeNil) + + _, err = badAuthz.GetPublicKeys(context.Background()) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "failed to load public key") + }) + + Convey("JWKS-format key is parsed correctly", func() { + jwksPub, _, err := ed25519.GenerateKey(rand.Reader) + So(err, ShouldBeNil) + + jwksJSON, err := json.Marshal(map[string]string{ + "jwks-key": ed25519KeyToJWKS(jwksPub, "jwks-kid"), + }) + So(err, ShouldBeNil) + + jwksMock := &mockSecretsManager{secretString: string(jwksJSON)} + jwksConf := &apiconfig.AWSSecretsManagerConfig{ + Region: "us-east-1", + SecretName: "test-secret", + RefreshInterval: time.Hour, + } + + jwksAuthz, err := api.NewAWSSecretsManager( + jwksConf, &mockAWSImplementation{client: jwksMock}, log.NewLogger("error", "")) + So(err, ShouldBeNil) + + keys, err := jwksAuthz.GetPublicKeys(context.Background()) + So(err, ShouldBeNil) + So(keys, ShouldContainKey, "jwks-key") + }) + + Convey("JWKS with multiple keys in one entry is rejected", func() { + // Build a JWKS with 2 keys to trigger the "expected 1 key" error. + jwksPub1, _, err := ed25519.GenerateKey(rand.Reader) + So(err, ShouldBeNil) + + jwksPub2, _, err := ed25519.GenerateKey(rand.Reader) + So(err, ShouldBeNil) + + keySet := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{ + {Key: jwksPub1, KeyID: "k1", Algorithm: "EdDSA", Use: "sig"}, + {Key: jwksPub2, KeyID: "k2", Algorithm: "EdDSA", Use: "sig"}, + }} + + multiData, err := json.Marshal(keySet) + So(err, ShouldBeNil) + + multiJSON, err := json.Marshal(map[string]string{ + "multi-key": string(multiData), + }) + So(err, ShouldBeNil) + + multiMock := &mockSecretsManager{secretString: string(multiJSON)} + multiConf := &apiconfig.AWSSecretsManagerConfig{ + Region: "us-east-1", + SecretName: "test-secret", + RefreshInterval: time.Hour, + } + + multiAuthz, err := api.NewAWSSecretsManager( + multiConf, &mockAWSImplementation{client: multiMock}, log.NewLogger("error", "")) + So(err, ShouldBeNil) + + _, err = multiAuthz.GetPublicKeys(context.Background()) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "expected 1 key in JWKS, found 2") + }) + }) +} + +func TestAWSSecretsManagerGetPublicKey(t *testing.T) { + Convey("Test GetPublicKey kid-based selection with mock", t, func() { + pubKey1, _, err := ed25519.GenerateKey(rand.Reader) + So(err, ShouldBeNil) + + pubKey2, _, err := ed25519.GenerateKey(rand.Reader) + So(err, ShouldBeNil) + + secretJSON, err := json.Marshal(map[string]string{ + "kid-alpha": ed25519KeyToPEM(pubKey1), + "kid-beta": ed25519KeyToPEM(pubKey2), + }) + So(err, ShouldBeNil) + + mock := &mockSecretsManager{secretString: string(secretJSON)} + conf := &apiconfig.AWSSecretsManagerConfig{ + Region: "us-east-1", + SecretName: "test-secret", + RefreshInterval: time.Hour, + } + + authz, err := api.NewAWSSecretsManager(conf, &mockAWSImplementation{client: mock}, log.NewLogger("error", "")) + So(err, ShouldBeNil) + + Convey("Matching kid returns the correct key", func() { + token := &jwt.Token{Header: map[string]any{"kid": "kid-alpha"}} + key, err := authz.GetPublicKey(context.Background(), token) + So(err, ShouldBeNil) + So(key, ShouldNotBeNil) + }) + + Convey("Missing kid header is rejected", func() { + token := &jwt.Token{Header: map[string]any{}} + _, err := authz.GetPublicKey(context.Background(), token) + So(err, ShouldNotBeNil) + So(err, ShouldWrap, zerr.ErrInvalidBearerToken) + }) + + Convey("Non-string kid header is rejected", func() { + token := &jwt.Token{Header: map[string]any{"kid": 12345}} + _, err := authz.GetPublicKey(context.Background(), token) + So(err, ShouldNotBeNil) + So(err, ShouldWrap, zerr.ErrInvalidBearerToken) + }) + + Convey("Unknown kid is rejected", func() { + token := &jwt.Token{Header: map[string]any{"kid": "kid-unknown"}} + _, err := authz.GetPublicKey(context.Background(), token) + So(err, ShouldNotBeNil) + So(err, ShouldWrap, zerr.ErrInvalidBearerToken) + }) + + Convey("GetSecretValue error is propagated through GetPublicKey", func() { + failMock := &mockSecretsManager{err: errAWSConnection} + failConf := &apiconfig.AWSSecretsManagerConfig{ + Region: "us-east-1", + SecretName: "test-secret", + RefreshInterval: time.Hour, + } + + failAuthz, err := api.NewAWSSecretsManager( + failConf, &mockAWSImplementation{client: failMock}, log.NewLogger("error", "")) + So(err, ShouldBeNil) + + token := &jwt.Token{Header: map[string]any{"kid": "any-kid"}} + _, err = failAuthz.GetPublicKey(context.Background(), token) + So(err, ShouldNotBeNil) + So(err, ShouldWrap, errAWSConnection) + }) + }) +} + +func TestAWSSecretsManagerBearerAuthorizerE2E(t *testing.T) { + Convey("Test BearerAuthorizer with ASM key function end-to-end", t, func() { + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + So(err, ShouldBeNil) + + const kid = "e2e-test-key" + + secretJSON, err := json.Marshal(map[string]string{ + kid: ed25519KeyToPEM(pubKey), + }) + So(err, ShouldBeNil) + + mock := &mockSecretsManager{secretString: string(secretJSON)} + conf := &apiconfig.AWSSecretsManagerConfig{ + Region: "us-east-1", + SecretName: "test-secret", + RefreshInterval: time.Hour, + } + + authz, err := api.NewAWSSecretsManager(conf, &mockAWSImplementation{client: mock}, log.NewLogger("error", "")) + So(err, ShouldBeNil) + + authorizer := api.NewBearerAuthorizer("realm", "service", authz.GetPublicKey) + + Convey("Valid EdDSA token with matching kid is authorized", func() { + now := time.Now() + claims := api.ClaimsWithAccess{ + Access: []api.ResourceAccess{ + { + Name: "test-repo", + Type: "repository", + Actions: []string{"pull"}, + }, + }, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = kid + + signedToken, err := token.SignedString(privKey) + So(err, ShouldBeNil) + + requested := &api.ResourceAction{ + Type: "repository", + Name: "test-repo", + Action: "pull", + } + + err = authorizer.Authorize(context.Background(), "Bearer "+signedToken, requested) + So(err, ShouldBeNil) + }) + + Convey("Token without kid header is rejected", func() { + now := time.Now() + claims := api.ClaimsWithAccess{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + // Deliberately omit kid header. + + signedToken, err := token.SignedString(privKey) + So(err, ShouldBeNil) + + err = authorizer.Authorize(context.Background(), "Bearer "+signedToken, nil) + So(err, ShouldWrap, zerr.ErrInvalidBearerToken) + }) + + Convey("Token with unknown kid is rejected", func() { + now := time.Now() + claims := api.ClaimsWithAccess{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = "nonexistent-kid" + + signedToken, err := token.SignedString(privKey) + So(err, ShouldBeNil) + + err = authorizer.Authorize(context.Background(), "Bearer "+signedToken, nil) + So(err, ShouldWrap, zerr.ErrInvalidBearerToken) + }) + }) +} + +func TestAWSSecretsManagerProductionImplementation(t *testing.T) { + Convey("Test production implementation coverage", t, func() { + impl := api.AWSSecretsManagerProviderImplementation{} + + Convey("LoadDefaultConfig does not panic", func() { + _, err := impl.LoadDefaultConfig(context.Background()) + if err != nil { + t.Log("no aws creds") + } else { + t.Log("aws creds available") + } + }) + + Convey("NewFromConfig returns a non-nil client", func() { + client := impl.NewFromConfig(aws.Config{}) + So(client, ShouldNotBeNil) + }) + }) +} diff --git a/pkg/api/bearer_oidc.go b/pkg/api/bearer_oidc.go index 899f0152..9b14f7a1 100644 --- a/pkg/api/bearer_oidc.go +++ b/pkg/api/bearer_oidc.go @@ -20,9 +20,9 @@ import ( "zotregistry.dev/zot/v2/pkg/log" ) -// oidcProviderRefreshInterval defines how often to refresh the OIDC provider configuration. -// With 1 minute interval, even if there are too many API calls authenticating via OIDC -// bearer tokens at once, we will only refresh the provider at most once per minute (safe). +// oidcProviderRefreshInterval defines the target interval for refreshing the public keys. +// With a 1 minute interval, repeated calls will generally reuse cached keys and only trigger +// a refresh roughly once per minute, but this is best-effort and not a strict upper bound. const oidcProviderRefreshInterval = 1 * time.Minute var bearerOIDCTokenMatch = regexp.MustCompile("(?i)bearer (.*)") diff --git a/pkg/api/bearer_test.go b/pkg/api/bearer_test.go index c2e06178..e8ce7144 100644 --- a/pkg/api/bearer_test.go +++ b/pkg/api/bearer_test.go @@ -1,11 +1,16 @@ package api_test import ( + "context" + "crypto/ed25519" "crypto/rand" "crypto/rsa" + "encoding/json" + "fmt" "testing" "time" + "github.com/go-jose/go-jose/v4" "github.com/golang-jwt/jwt/v5" . "github.com/smartystreets/goconvey/convey" @@ -23,11 +28,14 @@ func TestBearerAuthorizer(t *testing.T) { } pubKey := privKey.Public() + keyFunc := func(_ context.Context, token *jwt.Token) (any, error) { + return pubKey, nil + } - authorizer := api.NewBearerAuthorizer("realm", "service", pubKey) + authorizer := api.NewBearerAuthorizer("realm", "service", keyFunc) Convey("Empty authorization header given", func() { - err := authorizer.Authorize("", nil) + err := authorizer.Authorize(context.Background(), "", nil) So(err, ShouldBeError, zerr.ErrNoBearerToken) }) @@ -65,7 +73,7 @@ func TestBearerAuthorizer(t *testing.T) { Action: "*", } - err := authorizer.Authorize(authHeader, requested) + err := authorizer.Authorize(context.Background(), authHeader, requested) So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{}) So(err, ShouldBeError, zerr.ErrInsufficientScope) }) @@ -77,7 +85,7 @@ func TestBearerAuthorizer(t *testing.T) { Action: "pull", } - err := authorizer.Authorize(authHeader, requested) + err := authorizer.Authorize(context.Background(), authHeader, requested) So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{}) So(err, ShouldBeError, zerr.ErrInsufficientScope) }) @@ -89,7 +97,7 @@ func TestBearerAuthorizer(t *testing.T) { Action: "push", } - err := authorizer.Authorize(authHeader, requested) + err := authorizer.Authorize(context.Background(), authHeader, requested) So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{}) So(err, ShouldBeError, zerr.ErrInsufficientScope) }) @@ -101,12 +109,12 @@ func TestBearerAuthorizer(t *testing.T) { Action: "pull", } - err := authorizer.Authorize(authHeader, requested) + err := authorizer.Authorize(context.Background(), authHeader, requested) So(err, ShouldBeNil) }) Convey("Successful authorization without requested access", func() { - err := authorizer.Authorize(authHeader, nil) + err := authorizer.Authorize(context.Background(), authHeader, nil) So(err, ShouldBeNil) }) }) @@ -143,7 +151,7 @@ func TestBearerAuthorizer(t *testing.T) { Action: "pull", } - err = authorizer.Authorize("Bearer "+token, requested) + err = authorizer.Authorize(context.Background(), "Bearer "+token, requested) So(err, ShouldBeNil) }) @@ -176,7 +184,7 @@ func TestBearerAuthorizer(t *testing.T) { Action: "pull", } - err = authorizer.Authorize("Bearer "+token, requested) + err = authorizer.Authorize(context.Background(), "Bearer "+token, requested) So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{}) So(err, ShouldBeError, zerr.ErrInsufficientScope) }) @@ -215,7 +223,7 @@ func TestBearerAuthorizer(t *testing.T) { Action: "pull", } - err = authorizer.Authorize("Bearer "+token, requested) + err = authorizer.Authorize(context.Background(), "Bearer "+token, requested) So(err, ShouldBeNil) }) @@ -254,7 +262,7 @@ func TestBearerAuthorizer(t *testing.T) { Action: "pull", } - err = authorizer.Authorize("Bearer "+token, requested) + err = authorizer.Authorize(context.Background(), "Bearer "+token, requested) So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{}) So(err, ShouldBeError, zerr.ErrInsufficientScope) }) @@ -263,8 +271,125 @@ func TestBearerAuthorizer(t *testing.T) { Convey("Invalid token", func() { authHeader := "invalid" - err := authorizer.Authorize(authHeader, nil) + err := authorizer.Authorize(context.Background(), authHeader, nil) So(err, ShouldWrap, zerr.ErrInvalidBearerToken) }) }) } + +// TestBearerAuthorizerJWKSEdDSA verifies that an Ed25519 key pair in JWKS format can be used to +// sign and verify JWTs through the BearerAuthorizer. The hardcoded JWKS key pair below was +// generated externally using standard JWKS tooling. +func TestBearerAuthorizerJWKSEdDSA(t *testing.T) { + Convey("Test bearer authorization with JWKS Ed25519 key pair", t, func() { + // Hardcoded Ed25519 JWKS private key set (generated externally). + const privateJWKS = `{ + "keys": [ + { + "use": "sig", + "kty": "OKP", + "kid": "01f0ff96-0286-62c9-9fe0-68c6ac4f48e0", + "crv": "Ed25519", + "alg": "EdDSA", + "x": "3pL95mHbZYNG6-YT_MqXKibGQrXF7WziWk25EcgEJGs", + "d": "YJxZxGtBfy7lKKwuld1SQJn_9-YANmP0P_ZYG_ExUj4" + } + ] +}` + + // Hardcoded Ed25519 JWKS public key set (generated externally). + const publicJWKS = `{ + "keys": [ + { + "use": "sig", + "kty": "OKP", + "kid": "01f0ff96-0286-62c9-9fe0-68c6ac4f48e0", + "crv": "Ed25519", + "alg": "EdDSA", + "x": "3pL95mHbZYNG6-YT_MqXKibGQrXF7WziWk25EcgEJGs" + } + ] +}` + + // Parse the JWKS public key set (same logic as loadPublicKeyFromBytes). + var pubKeySet jose.JSONWebKeySet + err := json.Unmarshal([]byte(publicJWKS), &pubKeySet) + So(err, ShouldBeNil) + So(pubKeySet.Keys, ShouldHaveLength, 1) + + pubJWK := pubKeySet.Keys[0] + So(pubJWK.KeyID, ShouldEqual, "01f0ff96-0286-62c9-9fe0-68c6ac4f48e0") + + pubKey, ok := pubJWK.Key.(ed25519.PublicKey) + So(ok, ShouldBeTrue) + + // Parse the JWKS private key set to sign JWTs. + var privKeySet jose.JSONWebKeySet + err = json.Unmarshal([]byte(privateJWKS), &privKeySet) + So(err, ShouldBeNil) + So(privKeySet.Keys, ShouldHaveLength, 1) + + privJWK := privKeySet.Keys[0] + privKey, ok := privJWK.Key.(ed25519.PrivateKey) + So(ok, ShouldBeTrue) + + // Build a keyFunc that selects the public key by kid. + keyFunc := func(_ context.Context, token *jwt.Token) (any, error) { + kid, ok := token.Header["kid"] + if !ok { + return nil, fmt.Errorf("%w: missing kid", zerr.ErrInvalidBearerToken) + } + if kid != pubJWK.KeyID { + return nil, fmt.Errorf("%w: unknown kid %v", zerr.ErrInvalidBearerToken, kid) + } + + return pubKey, nil + } + + authorizer := api.NewBearerAuthorizer("realm", "service", keyFunc) + + Convey("Sign and verify a JWT using JWKS Ed25519 keys", func() { + now := time.Now() + claims := api.ClaimsWithAccess{ + Access: []api.ResourceAccess{ + { + Name: "test-repo", + Type: "repository", + Actions: []string{"pull"}, + }, + }, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "https://test-issuer", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = pubJWK.KeyID + + signedToken, err := token.SignedString(privKey) + So(err, ShouldBeNil) + + authHeader := "Bearer " + signedToken + + requested := &api.ResourceAction{ + Type: "repository", + Name: "test-repo", + Action: "pull", + } + err = authorizer.Authorize(context.Background(), authHeader, requested) + So(err, ShouldBeNil) + }) + + Convey("Verify a pre-signed JWT using JWKS Ed25519 public key", func() { + // This JWT was signed externally with the same private key above. + //nolint:lll + const preSignedJWT = `eyJhbGciOiJFZERTQSIsImtpZCI6IjAxZjBmZjk2LTAyODYtNjJjOS05ZmUwLTY4YzZhYzRmNDhlMCIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIwMWYwZmY5Ni0wYWNjLTY3YjMtYWY5Yy02OGM2YWM0ZjQ4ZTAiLCJpc3MiOiJodHRwczovL3Rlc3QtaXNzdWVyIiwic3ViIjoiYy1lN2YyMjFhY2ZlZmJiOWNlIiwiYXVkIjpbImZsdXgtb3BlcmF0b3IiXSwiZXhwIjoxODAxNTA0MDMzLCJpYXQiOjE3Njk5NjgwMzMsIm5iZiI6MTc2OTk2ODAzM30.-4_9d1llJ8nCvW8AQdyQKvidx6DtV9lm78pWhbS0w49hq5tRcx3bt_zGGyhj-VGPFIGF86LTL25hcgOVKLEZBg` + + authHeader := "Bearer " + preSignedJWT + err := authorizer.Authorize(context.Background(), authHeader, nil) + So(err, ShouldBeNil) + }) + }) +} diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index eefc4428..5644ed31 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -134,9 +134,22 @@ func (a *AuthConfig) IsBearerAuthEnabled() bool { // IsTraditionalBearerAuthEnabled checks if traditional Bearer authentication is enabled in this auth config. func (a *AuthConfig) IsTraditionalBearerAuthEnabled() bool { + return a.IsTraditionalBearerAuthEnabledWithCert() || a.IsTraditionalBearerAuthEnabledWithASM() +} + +// IsTraditionalBearerAuthEnabledWithCert checks if traditional Bearer authentication with a +// static cert is enabled in this auth config. +func (a *AuthConfig) IsTraditionalBearerAuthEnabledWithCert() bool { return a != nil && a.Bearer != nil && a.Bearer.Cert != "" && a.Bearer.Realm != "" && a.Bearer.Service != "" } +// IsTraditionalBearerAuthEnabledWithASM checks if traditional Bearer authentication with +// AWS Secrets Manager is enabled in this auth config. +func (a *AuthConfig) IsTraditionalBearerAuthEnabledWithASM() bool { + return a != nil && a.Bearer != nil && a.Bearer.AWSSecretsManager != nil && + a.Bearer.Realm != "" && a.Bearer.Service != "" +} + // IsOIDCBearerAuthEnabled checks if OIDC Bearer authentication is enabled in this auth config. func (a *AuthConfig) IsOIDCBearerAuthEnabled() bool { return a != nil && a.Bearer != nil && a.Bearer.OIDC.IsEnabled() @@ -193,8 +206,12 @@ type BearerConfig struct { Realm string Service string Cert string + // OIDC configuration for workload identity authentication OIDC BearerOIDCConfigs `json:"oidc,omitempty" mapstructure:"oidc,omitempty"` + + // AWSSecretsManager configuration for retrieving JWT Bearer verification keys. + AWSSecretsManager *AWSSecretsManagerConfig `json:"awsSecretsManager,omitempty" mapstructure:"awsSecretsManager,omitempty"` //nolint:lll } // BearerOIDCConfigs is a slice of BearerOIDCConfig. @@ -240,6 +257,21 @@ type BearerOIDCConfig struct { SkipIssuerVerification bool `json:"skipIssuerVerification,omitempty" mapstructure:"skipIssuerVerification,omitempty"` } +// AWSSecretsManagerConfig configures retrieval of JWT verification keys from AWS Secrets Manager. +// The secret format is expected to be a JSON object where each key is a key ID and the value is +// the corresponding PEM-or-JWKS-encoded public key. +type AWSSecretsManagerConfig struct { + // Region is the AWS region where the secret is stored. + Region string `json:"region" mapstructure:"region"` + + // SecretName is the name of the secret in AWS Secrets Manager. + SecretName string `json:"secretName" mapstructure:"secretName"` + + // RefreshInterval specifies how often to refresh the secret from AWS Secrets Manager. + // Default: 1 minute. + RefreshInterval time.Duration `json:"refreshInterval,omitempty" mapstructure:"refreshInterval,omitempty"` +} + type SessionKeys struct { HashKey string EncryptKey string `mapstructure:",omitempty"` diff --git a/pkg/cli/server/root.go b/pkg/cli/server/root.go index e779b5d7..ee61d6e3 100644 --- a/pkg/cli/server/root.go +++ b/pkg/cli/server/root.go @@ -580,6 +580,10 @@ func validateConfiguration(config *config.Config, logger zlog.Logger) error { return err } + if err := validateBearerConfig(config, logger); err != nil { + return err + } + if err := validateSync(config, logger); err != nil { return err } @@ -711,6 +715,49 @@ func validateOpenIDConfig(cfg *config.Config, logger zlog.Logger) error { return nil } +func validateBearerConfig(cfg *config.Config, logger zlog.Logger) error { + authConfig := cfg.CopyAuthConfig() + if authConfig == nil || authConfig.Bearer == nil { + return nil + } + + bearer := authConfig.Bearer + + if bearer.Cert != "" && bearer.AWSSecretsManager != nil { + msg := "cannot configure both cert and awsSecretsManager for bearer authentication" + logger.Error().Err(zerr.ErrBadConfig).Msg(msg) + + return fmt.Errorf("%w: %s", zerr.ErrBadConfig, msg) + } + + if bearer.AWSSecretsManager != nil { + asm := bearer.AWSSecretsManager + + if asm.Region == "" { + msg := "awsSecretsManager region must be specified" + logger.Error().Err(zerr.ErrBadConfig).Msg(msg) + + return fmt.Errorf("%w: %s", zerr.ErrBadConfig, msg) + } + + if asm.SecretName == "" { + msg := "awsSecretsManager secretName must be specified" + logger.Error().Err(zerr.ErrBadConfig).Msg(msg) + + return fmt.Errorf("%w: %s", zerr.ErrBadConfig, msg) + } + + if asm.RefreshInterval < 0 { + msg := "awsSecretsManager refreshInterval must be non-negative" + logger.Error().Err(zerr.ErrBadConfig).Msg(msg) + + return fmt.Errorf("%w: %s", zerr.ErrBadConfig, msg) + } + } + + return nil +} + func validateAuthzPolicies(config *config.Config, logger zlog.Logger) error { authConfig := config.CopyAuthConfig() accessControlConfig := config.CopyAccessControlConfig() diff --git a/pkg/cli/server/root_test.go b/pkg/cli/server/root_test.go index 36ea6244..f5aa8641 100644 --- a/pkg/cli/server/root_test.go +++ b/pkg/cli/server/root_test.go @@ -3079,3 +3079,107 @@ func TestRetentionDelayDefaults(t *testing.T) { }) }) } + +func TestBearerASMConfigValidation(t *testing.T) { + Convey("Test bearer ASM config validation", t, func() { + Convey("Reject both cert and awsSecretsManager", func() { + content := `{ + "storage": {"rootDirectory": "/tmp/zot"}, + "http": { + "address": "127.0.0.1", "port": "8080", + "auth": { + "bearer": { + "realm": "test", "service": "test", + "cert": "/some/cert.pem", + "awsSecretsManager": {"region": "us-east-1", "secretName": "my-secret"} + } + } + } + }` + cfg := config.New() + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + err := cli.LoadConfiguration(cfg, tmpfile) + So(err, ShouldNotBeNil) + So(err, ShouldWrap, zerr.ErrBadConfig) + }) + + Convey("Reject empty region", func() { + content := `{ + "storage": {"rootDirectory": "/tmp/zot"}, + "http": { + "address": "127.0.0.1", "port": "8080", + "auth": { + "bearer": { + "realm": "test", "service": "test", + "awsSecretsManager": {"region": "", "secretName": "my-secret"} + } + } + } + }` + cfg := config.New() + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + err := cli.LoadConfiguration(cfg, tmpfile) + So(err, ShouldNotBeNil) + So(err, ShouldWrap, zerr.ErrBadConfig) + }) + + Convey("Reject empty secretName", func() { + content := `{ + "storage": {"rootDirectory": "/tmp/zot"}, + "http": { + "address": "127.0.0.1", "port": "8080", + "auth": { + "bearer": { + "realm": "test", "service": "test", + "awsSecretsManager": {"region": "us-east-1", "secretName": ""} + } + } + } + }` + cfg := config.New() + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + err := cli.LoadConfiguration(cfg, tmpfile) + So(err, ShouldNotBeNil) + So(err, ShouldWrap, zerr.ErrBadConfig) + }) + + Convey("Reject negative refreshInterval", func() { + content := `{ + "storage": {"rootDirectory": "/tmp/zot"}, + "http": { + "address": "127.0.0.1", "port": "8080", + "auth": { + "bearer": { + "realm": "test", "service": "test", + "awsSecretsManager": {"region": "us-east-1", "secretName": "my-secret", "refreshInterval": "-1s"} + } + } + } + }` + cfg := config.New() + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + err := cli.LoadConfiguration(cfg, tmpfile) + So(err, ShouldNotBeNil) + So(err, ShouldWrap, zerr.ErrBadConfig) + }) + + Convey("Valid ASM config is accepted", func() { + content := `{ + "storage": {"rootDirectory": "/tmp/zot"}, + "http": { + "address": "127.0.0.1", "port": "8080", + "auth": { + "bearer": { + "realm": "test", "service": "test", + "awsSecretsManager": {"region": "us-east-1", "secretName": "my-secret"} + } + } + } + }` + cfg := config.New() + tmpfile := MakeTempFileWithContent(t, "zot-test.json", content) + err := cli.LoadConfiguration(cfg, tmpfile) + So(err, ShouldBeNil) + }) + }) +}