mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 12:58:02 +08:00
feat(jwt-asm): support AWS Secrets Manager for JWT verification (#3763)
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
This commit is contained in:
@@ -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.
|
||||
Executable
+535
@@ -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}" <<EOF
|
||||
{
|
||||
"distSpecVersion": "1.1.1",
|
||||
"storage": {
|
||||
"rootDirectory": "${ZOT_STORAGE}"
|
||||
},
|
||||
"http": {
|
||||
"address": "127.0.0.1",
|
||||
"port": "${ZOT_PORT}",
|
||||
"compat": ["docker2s2"],
|
||||
"auth": {
|
||||
"bearer": {
|
||||
"realm": "zot",
|
||||
"service": "zot-service",
|
||||
"awsSecretsManager": {
|
||||
"region": "${AWS_REGION}",
|
||||
"secretName": "${SECRET_NAME}",
|
||||
"refreshInterval": "30s"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"log": {
|
||||
"level": "debug"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
log_info "Zot configuration:"
|
||||
jq . "${ZOT_CONFIG}"
|
||||
|
||||
# Step 5: Start zot
|
||||
# When using LocalStack, set AWS_ENDPOINT_URL so the AWS SDK in zot talks to LocalStack.
|
||||
# Also set dummy credentials for LocalStack (it doesn't validate them).
|
||||
log_info "Starting zot..."
|
||||
if [ "$USE_REAL_AWS" = true ]; then
|
||||
"${ZOT_BINARY}" serve "${ZOT_CONFIG}" &
|
||||
else
|
||||
AWS_ENDPOINT_URL="http://127.0.0.1:${LOCALSTACK_PORT}" \
|
||||
AWS_ACCESS_KEY_ID="test" \
|
||||
AWS_SECRET_ACCESS_KEY="test" \
|
||||
"${ZOT_BINARY}" serve "${ZOT_CONFIG}" &
|
||||
fi
|
||||
ZOT_PID=$!
|
||||
echo "${ZOT_PID}" > "${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 <<EOFCONFIG
|
||||
{
|
||||
"auths": {
|
||||
"127.0.0.1:${ZOT_PORT}": {
|
||||
"registryToken": "${token}"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOFCONFIG
|
||||
}
|
||||
|
||||
remove_crane_auth() {
|
||||
rm -f /tmp/zot-asm-docker/config.json
|
||||
}
|
||||
|
||||
REGISTRY="127.0.0.1:${ZOT_PORT}"
|
||||
|
||||
# =============================================================================
|
||||
# TESTS
|
||||
# =============================================================================
|
||||
|
||||
# TEST 1: Basic authentication check (GET /v2/ with valid token)
|
||||
log_info "TEST 1: Verifying basic authentication with valid token..."
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: Bearer ${TOKEN_NO_ACCESS}" \
|
||||
"http://${REGISTRY}/v2/")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
log_info "TEST 1 PASSED: Authentication succeeded (HTTP $HTTP_CODE)"
|
||||
else
|
||||
log_error "TEST 1 FAILED: Expected 200, got HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# TEST 2: Authentication fails without token
|
||||
log_info "TEST 2: Verifying authentication fails without token..."
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
"http://${REGISTRY}/v2/")
|
||||
|
||||
if [ "$HTTP_CODE" = "401" ]; then
|
||||
log_info "TEST 2 PASSED: No token correctly rejected (HTTP $HTTP_CODE)"
|
||||
else
|
||||
log_error "TEST 2 FAILED: Expected 401, got HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# TEST 3: Authentication fails with invalid token
|
||||
log_info "TEST 3: Verifying authentication fails with invalid token..."
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: Bearer invalid.token.here" \
|
||||
"http://${REGISTRY}/v2/")
|
||||
|
||||
if [ "$HTTP_CODE" = "401" ]; then
|
||||
log_info "TEST 3 PASSED: Invalid token correctly rejected (HTTP $HTTP_CODE)"
|
||||
else
|
||||
log_error "TEST 3 FAILED: Expected 401, got HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# TEST 4: Push OCI image using crane WITH push+pull token
|
||||
log_info "TEST 4: Pushing OCI image using crane with push+pull token..."
|
||||
setup_crane_docker_config "$TOKEN_PUSH_PULL"
|
||||
|
||||
PUSH_OUTPUT=$(DOCKER_CONFIG=/tmp/zot-asm-docker crane copy --insecure --platform linux/amd64 \
|
||||
"${BUSYBOX_IMAGE}" "${REGISTRY}/test-repo:v1" 2>&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)"
|
||||
Reference in New Issue
Block a user