From bf619c570e194beee8bb79aeb9d5f23b856c7204 Mon Sep 17 00:00:00 2001 From: Matheus Pimenta Date: Sun, 25 Jan 2026 05:03:53 +0000 Subject: [PATCH] Introduce support for OIDC workload identity federation (#3711) * feat(oidc): introduce support for OIDC workload identity federation Signed-off-by: Matheus Pimenta * feat(oidc): add e2e test for bearer OIDC and a kind cluster Signed-off-by: Matheus Pimenta * feat(oidc): make OIDC workload identity federation its own feature Signed-off-by: Matheus Pimenta * feat(oidc): move errors to the errors package Signed-off-by: Matheus Pimenta * feat(oidc): fix race in cel package Signed-off-by: Matheus Pimenta * feat(oidc): compile cel expressions Signed-off-by: Matheus Pimenta --------- Signed-off-by: Matheus Pimenta --- .github/workflows/golangci-lint.yaml | 3 + .github/workflows/nightly.yaml | 26 + Makefile | 14 +- errors/errors.go | 8 + examples/README-OIDC-WORKLOAD-IDENTITY.md | 405 +++++++ examples/config-bearer-oidc-workload.json | 40 + examples/kind/kind-oidc-workload-identity.sh | 1004 ++++++++++++++++++ go.mod | 8 +- go.sum | 6 + pkg/api/authn.go | 119 ++- pkg/api/authn_test.go | 577 ++++++++++ pkg/api/authz.go | 13 +- pkg/api/bearer.go | 4 +- pkg/api/bearer_oidc.go | 244 +++++ pkg/api/bearer_oidc_test.go | 834 +++++++++++++++ pkg/api/config/config.go | 86 ++ pkg/api/config/config_test.go | 62 ++ pkg/api/controller.go | 6 +- pkg/cel/claim_processor.go | 257 +++++ pkg/cel/claim_processor_test.go | 644 +++++++++++ pkg/cel/expression.go | 187 ++++ pkg/cel/expression_test.go | 621 +++++++++++ pkg/cli/server/config_reloader_test.go | 24 +- pkg/cli/server/root.go | 2 +- pkg/cli/server/root_test.go | 9 +- 25 files changed, 5151 insertions(+), 52 deletions(-) create mode 100644 examples/README-OIDC-WORKLOAD-IDENTITY.md create mode 100644 examples/config-bearer-oidc-workload.json create mode 100755 examples/kind/kind-oidc-workload-identity.sh create mode 100644 pkg/api/bearer_oidc.go create mode 100644 pkg/api/bearer_oidc_test.go create mode 100644 pkg/cel/claim_processor.go create mode 100644 pkg/cel/claim_processor_test.go create mode 100644 pkg/cel/expression.go create mode 100644 pkg/cel/expression_test.go diff --git a/.github/workflows/golangci-lint.yaml b/.github/workflows/golangci-lint.yaml index 15be9f12..29bc111a 100644 --- a/.github/workflows/golangci-lint.yaml +++ b/.github/workflows/golangci-lint.yaml @@ -44,6 +44,9 @@ jobs: # skip-build-cache: true env: GOEXPERIMENT: jsonv2 + - name: Check go.mod and go.sum are up to date + run: | + make modcheck - name: Run linter from make target run: | make check diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index a129990a..c280da98 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -202,6 +202,32 @@ jobs: sudo ./scripts/enable_userns.sh ./examples/kind/kind-ci.sh + oidc-workload-identity: + name: OIDC Workload Identity E2E + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: 1.25.x + - name: Install dependencies + run: | + cd $GITHUB_WORKSPACE + make check-blackbox-prerequisites + go mod download + sudo apt-get update + sudo apt-get install libgpgme-dev libassuan-dev libbtrfs-dev libdevmapper-dev pkg-config rpm uidmap jq + - name: Log in to GitHub Docker Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - name: Run OIDC workload identity tests + run: | + sudo ./scripts/enable_userns.sh + ./examples/kind/kind-oidc-workload-identity.sh + cloud-scale-out: name: s3+dynamodb scale-out runs-on: oracle-vm-16cpu-64gb-x86-64 diff --git a/Makefile b/Makefile index 8f7cd0f8..152c2cf4 100644 --- a/Makefile +++ b/Makefile @@ -104,7 +104,7 @@ else endif .PHONY: all -all: modcheck swaggercheck binary binary-minimal binary-debug cli bench exporter-minimal verify-config check check-gh-actions test covhtml +all: swaggercheck binary binary-minimal binary-debug cli bench exporter-minimal verify-config check check-gh-actions test covhtml .PHONY: modtidy modtidy: @@ -182,30 +182,30 @@ gen-protobuf: $(PROTOC) .PHONY: binary-minimal binary-minimal: EXTENSIONS= -binary-minimal: modcheck build-metadata +binary-minimal: build-metadata env CGO_ENABLED=0 GOEXPERIMENT=jsonv2 GOOS=$(OS) GOARCH=$(ARCH) go build -o bin/zot-$(OS)-$(ARCH)-minimal$(BIN_EXT) $(BUILDMODE_FLAGS) -v -trimpath -ldflags "-X $(CONFIG_RELEASE_TAG)=${RELEASE_TAG} -X $(CONFIG_COMMIT)=${COMMIT} -X $(CONFIG_BINARY_TYPE)=minimal -X $(CONFIG_GO_VERSION)=${GO_VERSION} -s -w" ./cmd/zot .PHONY: binary binary: $(if $(findstring ui,$(BUILD_LABELS)), ui) -binary: modcheck build-metadata +binary: build-metadata env CGO_ENABLED=0 GOEXPERIMENT=jsonv2 GOOS=$(OS) GOARCH=$(ARCH) go build -o bin/zot-$(OS)-$(ARCH)$(BIN_EXT) $(BUILDMODE_FLAGS) $(GO_CMD_TAGS) -v -trimpath -ldflags "-X $(CONFIG_RELEASE_TAG)=${RELEASE_TAG} -X $(CONFIG_COMMIT)=${COMMIT} -X $(CONFIG_BINARY_TYPE)=$(extended-name) -X $(CONFIG_GO_VERSION)=${GO_VERSION} -s -w" ./cmd/zot .PHONY: binary-debug binary-debug: $(if $(findstring ui,$(BUILD_LABELS)), ui) -binary-debug: modcheck swaggercheck build-metadata +binary-debug: swaggercheck build-metadata env CGO_ENABLED=0 GOEXPERIMENT=jsonv2 GOOS=$(OS) GOARCH=$(ARCH) go build -o bin/zot-$(OS)-$(ARCH)-debug$(BIN_EXT) $(BUILDMODE_FLAGS) -tags $(BUILD_LABELS),debug -v -gcflags all='-N -l' -ldflags "-X $(CONFIG_RELEASE_TAG)=${RELEASE_TAG} -X $(CONFIG_COMMIT)=${COMMIT} -X $(CONFIG_BINARY_TYPE)=$(extended-name) -X $(CONFIG_GO_VERSION)=${GO_VERSION}" ./cmd/zot .PHONY: cli -cli: modcheck build-metadata +cli: build-metadata env CGO_ENABLED=0 GOEXPERIMENT=jsonv2 GOOS=$(OS) GOARCH=$(ARCH) go build -o bin/zli-$(OS)-$(ARCH)$(BIN_EXT) $(BUILDMODE_FLAGS) -tags $(BUILD_LABELS),search -v -trimpath -ldflags "-X $(CONFIG_COMMIT)=${COMMIT} -X $(CONFIG_BINARY_TYPE)=$(extended-name) -X $(CONFIG_GO_VERSION)=${GO_VERSION} -s -w" ./cmd/zli .PHONY: bench -bench: modcheck build-metadata +bench: build-metadata env CGO_ENABLED=0 GOEXPERIMENT=jsonv2 GOOS=$(OS) GOARCH=$(ARCH) go build -o bin/zb-$(OS)-$(ARCH)$(BIN_EXT) $(BUILDMODE_FLAGS) $(GO_CMD_TAGS) -v -trimpath -ldflags "-X $(CONFIG_COMMIT)=${COMMIT} -X $(CONFIG_BINARY_TYPE)=$(extended-name) -X $(CONFIG_GO_VERSION)=${GO_VERSION} -s -w" ./cmd/zb .PHONY: exporter-minimal exporter-minimal: EXTENSIONS= -exporter-minimal: modcheck build-metadata +exporter-minimal: build-metadata env CGO_ENABLED=0 GOEXPERIMENT=jsonv2 GOOS=$(OS) GOARCH=$(ARCH) go build -o bin/zxp-$(OS)-$(ARCH)$(BIN_EXT) $(BUILDMODE_FLAGS) -v -trimpath ./cmd/zxp .PHONY: test-prereq diff --git a/errors/errors.go b/errors/errors.go index 326621c1..0fcb1708 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -178,6 +178,7 @@ var ( ErrReceivedUnexpectedAuthHeader = errors.New("received unexpected www-authenticate header") ErrNoBearerToken = errors.New("no bearer token given") ErrInvalidBearerToken = errors.New("invalid bearer token given") + ErrInvalidOrUnreachableOIDCIssuer = errors.New("invalid or unreachable oidc issuer") ErrInsufficientScope = errors.New("bearer token does not have sufficient scope") ErrCouldNotLoadPublicKey = errors.New("failed to load public key") ErrEventTypeEmpty = errors.New("event type empty") @@ -196,4 +197,11 @@ var ( ErrNoEmailSANFound = errors.New("no Email SAN found") ErrEmailSANIndexOutOfRange = errors.New("Email SAN index out of range") ErrUnsupportedIdentityAttribute = errors.New("unsupported identity attribute") + ErrOIDCNoAudiences = errors.New("at least one audience must be specified") + ErrOIDCInvalidAudiences = errors.New("invalid audiences claim in token") + ErrOIDCEmptyAudience = errors.New("audience is empty") + ErrOIDCEmptyVariableName = errors.New("variable name is empty") + ErrOIDCEmptyValidationMsg = errors.New("validation error message is empty") + ErrOIDCValidationFailed = errors.New("OIDC claim validation failed") + ErrOIDCAudienceMismatch = errors.New("token audience does not match any of the expected audiences") ) diff --git a/examples/README-OIDC-WORKLOAD-IDENTITY.md b/examples/README-OIDC-WORKLOAD-IDENTITY.md new file mode 100644 index 00000000..3e4e112b --- /dev/null +++ b/examples/README-OIDC-WORKLOAD-IDENTITY.md @@ -0,0 +1,405 @@ +# OIDC Workload Identity Authentication + +This document describes how to configure Zot to authenticate workloads using OIDC ID tokens, enabling secret-less authentication for automated workflows. + +## Overview + +OIDC Workload Identity authentication allows workloads (e.g., Kubernetes pods, CI/CD pipelines) to authenticate to Zot using OIDC ID tokens instead of static credentials. This is similar to how cloud providers implement "workload identity" features and how Kubernetes handles external OIDC authentication. + +## Benefits + +- **Secret-less Authentication**: No need to manage static credentials +- **Automatic Credential Rotation**: Tokens are short-lived and automatically rotated +- **Fine-grained Access Control**: Map OIDC claims to Zot identities and groups using CEL expressions +- **Kubernetes Native**: Works seamlessly with Kubernetes ServiceAccount tokens +- **Multi-Provider Support**: Configure multiple OIDC issuers for different workload types (e.g. multiple clusters) +- **Standards-based**: Uses standard OIDC protocols + +## Configuration + +### Basic Configuration + +Add OIDC workload identity configuration to your bearer authentication settings. The `oidc` field accepts an array of provider configurations: + +```json +{ + "http": { + "auth": { + "bearer": { + "realm": "zot", + "service": "zot-service", + "oidc": [ + { + "issuer": "https://kubernetes.default.svc.cluster.local", + "audiences": ["zot"] + } + ] + } + } + } +} +``` + +### Configuration Options + +- **`issuer`** (required): The OIDC issuer URL. This is the identity provider that signs the tokens. + - Example: `"https://kubernetes.default.svc.cluster.local"` + - Example: `"https://token.actions.githubusercontent.com"` + +- **`audiences`** (required): List of acceptable audiences for the OIDC token. At least one must be specified. + - Example: `["zot", "https://zot.example.com"]` + +- **`claimMapping`** (optional): CEL-based configuration for validating and mapping OIDC claims. + - **`variables`**: List of variables to extract from claims using CEL expressions + - **`validations`**: List of validation rules with CEL expressions + - **`username`**: CEL expression to extract the username. Default: `"claims.iss + '/' + claims.sub"` + - **`groups`**: CEL expression to extract groups. Default: none (no groups extracted) + +- **`skipIssuerVerification`** (optional): Skip issuer verification (for testing only). Default: `false`. + +### CEL Expressions + +Zot uses [Common Expression Language (CEL)](https://github.com/google/cel-go) for flexible claim validation and mapping. CEL expressions have access to: + +- **`claims`**: The OIDC token claims as a map (e.g., `claims.sub`, `claims.email`) +- **`vars`**: Previously extracted variables (for use in validations and username/groups expressions) + +#### Example CEL Expressions + +| Expression | Description | +|------------|-------------| +| `claims.sub` | Extract the subject claim | +| `claims.email` | Extract the email claim | +| `claims.groups` | Extract the groups claim | +| `claims['kubernetes.io/serviceaccount/namespace']` | Extract claims with special characters | +| `claims.repository_owner + '/' + claims.sub` | Concatenate multiple claims | +| `claims.email.split('@')[0]` | Extract username from email | +| `claims.org in ['allowed-org-1', 'allowed-org-2']` | Check if org is in allowed list | +| `claims.email_verified == true` | Validate email is verified | + +### Complete Example + +In the example below, the username is mapped from both the issuer and subject +claims to uniquely identify Kubernetes ServiceAccounts across different clusters. +Note that `claims.iss + '/' + claims.sub` is the default username mapping if none +is specified (so the whole `claimMapping` section could be omitted in this 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", + "oidc": [ + { + "issuer": "https://kubernetes.default.svc.cluster.local", + "audiences": ["zot", "https://zot.example.com"], + "claimMapping": { + "username": "claims.iss + '/' + claims.sub" + } + } + ] + } + }, + "accessControl": { + "repositories": { + "**": { + "policies": [ + { + "users": ["https://kubernetes.default.svc.cluster.local/system:serviceaccount:flux-system:source-controller"], + "actions": ["read", "create", "update", "delete"] + } + ] + } + } + } + }, + "log": { + "level": "info" + } +} +``` + +### Advanced Configuration with CEL Validations + +Use CEL expressions to validate claims and extract complex usernames: + +```json +{ + "http": { + "auth": { + "bearer": { + "oidc": [ + { + "issuer": "https://token.actions.githubusercontent.com", + "audiences": ["zot"], + "claimMapping": { + "variables": [ + { + "name": "repo", + "expression": "claims.repository" + }, + { + "name": "owner", + "expression": "claims.repository_owner" + } + ], + "validations": [ + { + "expression": "vars.owner == 'my-org'", + "message": "only my-org repositories are allowed" + }, + { + "expression": "claims.ref.startsWith('refs/heads/')", + "message": "must be a branch reference" + } + ], + "username": "vars.repo", + "groups": "['github-actions', 'ci']" + } + } + ] + } + } + } +} +``` + +### Multiple OIDC Providers + +Configure multiple OIDC providers to support different identity sources. Zot will try each provider in order until one successfully authenticates the token: + +```json +{ + "http": { + "auth": { + "bearer": { + "oidc": [ + { + "issuer": "https://kubernetes.default.svc.cluster.local", + "audiences": ["zot"], + "claimMapping": { + "variables": [ + { + "name": "ns", + "expression": "claims['kubernetes.io/serviceaccount/namespace']" + }, + { + "name": "sa", + "expression": "claims['kubernetes.io/serviceaccount/service-account.name']" + } + ], + "username": "vars.ns + ':' + vars.sa", + "groups": "['k8s-workloads']" + } + }, + { + "issuer": "https://token.actions.githubusercontent.com", + "audiences": ["zot"], + "claimMapping": { + "username": "claims.repository", + "groups": "['github-actions']" + } + } + ] + } + } + } +} +``` + +## Usage + +### Kubernetes ServiceAccount Tokens + +When running in Kubernetes, workloads can use their ServiceAccount tokens to authenticate: + +```bash +# Get the ServiceAccount token +TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) + +# Use it to authenticate to Zot +curl -H "Authorization: Bearer $TOKEN" https://zot.example.com/v2/_catalog +``` + +### Flux Integration + +Flux can use Kubernetes ServiceAccount tokens to authenticate to Zot without secrets: + +```yaml +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: zot-repo + namespace: flux-system +spec: + url: oci://zot.example.com/v2/manifests + credentials: ServiceAccountToken + serviceAccountName: my-tenant-sa # optional. if omitted, defaults to the source-controller ServiceAccount +``` + +Note: The configuration above is currently a proposal from the Flux maintainers +and may change until officially released. For more details, see this +[RFC](https://github.com/fluxcd/flux2/issues/5681). + +### GitHub Actions + +GitHub Actions can use OIDC tokens to authenticate: + +```yaml +- name: Login to Zot + run: | + TOKEN=$(curl -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=zot" | jq -r .value) + echo $TOKEN | docker login -u oauth --password-stdin zot.example.com +``` + +## Token Claims + +### Required Claims + +- **`iss`**: Issuer URL (must match configured issuer) +- **`aud`**: Audience (must match one of the configured audiences) +- **`sub`**: Subject (used as username by default) +- **`exp`**: Expiration time +- **`iat`**: Issued at time + +### Optional Claims + +- **`groups`**: Array of group names for authorization (requires CEL `groups` expression) +- **`preferred_username`**: Can be used as username with CEL expression +- **`email`**: Can be used as username with CEL expression +- **`name`**: Can be used as username with CEL expression + +### Example Token Payload + +```json +{ + "iss": "https://kubernetes.default.svc.cluster.local", + "aud": ["zot"], + "sub": "system:serviceaccount:flux-system:source-controller", + "exp": 1705258800, + "iat": 1705255200, + "kubernetes.io/serviceaccount/namespace": "flux-system", + "kubernetes.io/serviceaccount/service-account.name": "source-controller" +} +``` + +## Access Control + +Use Zot's access control policies to grant permissions based on the OIDC identity: + +```json +{ + "accessControl": { + "repositories": { + "app/**": { + "policies": [ + { + "users": ["system:serviceaccount:prod:app-controller"], + "actions": ["read", "create", "update"] + } + ] + }, + "**": { + "policies": [ + { + "users": ["system:serviceaccount:default:admin"], + "actions": ["read", "create", "update", "delete"] + } + ], + "defaultPolicy": ["read"] + } + } + } +} +``` + +## Compatibility + +### Traditional Bearer Authentication + +OIDC workload identity can coexist with traditional bearer authentication. If both are configured, Zot will try OIDC authentication first, then fall back to traditional bearer token authentication: + +```json +{ + "http": { + "auth": { + "bearer": { + "realm": "https://auth.myreg.io/auth/token", + "service": "myauth", + "cert": "/etc/zot/auth.crt", + "oidc": [ + { + "issuer": "https://kubernetes.default.svc.cluster.local", + "audiences": ["zot"] + } + ] + } + } + } +} +``` + +### Other Authentication Methods + +OIDC workload identity is only available with bearer authentication. For other authentication methods (htpasswd, LDAP, OAuth2 for humans), continue using the existing configuration options. + +## Troubleshooting + +### Enable Debug Logging + +Set log level to `debug` to see detailed authentication logs: + +```json +{ + "log": { + "level": "debug" + } +} +``` + +### Common Issues + +1. **Token verification failed**: Check that the issuer URL is correct and reachable from Zot. + +2. **Audience not accepted**: Ensure the token's `aud` claim matches one of the configured audiences. + +3. **Token expired**: OIDC tokens are typically short-lived. Ensure your workload is obtaining fresh tokens. + +4. **CEL expression error**: Check the CEL expression syntax. Use `claims.field` for simple fields or `claims['field-name']` for fields with special characters. + +5. **Validation failed**: Check that your token claims satisfy all configured validation expressions. + +6. **JWKS endpoint not reachable**: Verify network connectivity to the OIDC issuer's JWKS endpoint. Note: Zot lazily initializes the OIDC provider on first authentication, so startup won't fail if the issuer is temporarily unreachable. + +7. **No username found**: Ensure the CEL expression for username evaluates to a non-empty string. Check that the required claims exist in the token. + +## Security Considerations + +1. **Token Expiration**: Always use short-lived tokens (typically 1 hour or less). + +2. **Audience Validation**: Always specify audiences to prevent token reuse across services. + +3. **TLS**: Use TLS for all communication to protect tokens in transit. + +4. **Issuer Verification**: Never disable issuer verification in production. + +5. **Access Control**: Always configure access control policies to limit what authenticated workloads can do. + +6. **CEL Validations**: Use CEL validations to enforce additional security constraints (e.g., require email verification, restrict to specific organizations). + +## References + +- [OIDC Specification](https://openid.net/specs/openid-connect-core-1_0.html) +- [CEL Language Definition](https://github.com/google/cel-spec) +- [Kubernetes OIDC Authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens) +- [Flux Workload Identity RFC](https://github.com/fluxcd/flux2/tree/main/rfcs/0010-multi-tenant-workload-identity) +- [GitHub Actions OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) diff --git a/examples/config-bearer-oidc-workload.json b/examples/config-bearer-oidc-workload.json new file mode 100644 index 00000000..20b11626 --- /dev/null +++ b/examples/config-bearer-oidc-workload.json @@ -0,0 +1,40 @@ +{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080", + "auth": { + "bearer": { + "realm": "zot", + "service": "zot-service", + "oidc": [ + { + "issuer": "https://kubernetes.default.svc.cluster.local", + "audiences": ["zot", "https://zot.example.com"], + "claimMapping": { + "username": "claims.sub" + } + } + ] + } + }, + "accessControl": { + "repositories": { + "**": { + "policies": [ + { + "users": ["system:serviceaccount:flux-system:source-controller"], + "actions": ["read", "create", "update", "delete"] + } + ] + } + } + } + }, + "log": { + "level": "info" + } +} diff --git a/examples/kind/kind-oidc-workload-identity.sh b/examples/kind/kind-oidc-workload-identity.sh new file mode 100755 index 00000000..230ac178 --- /dev/null +++ b/examples/kind/kind-oidc-workload-identity.sh @@ -0,0 +1,1004 @@ +#!/bin/bash +# OIDC Workload Identity E2E Test +# +# This script tests OIDC workload identity federation with Kubernetes ServiceAccount tokens. +# It uses the native Kubernetes ServiceAccount issuer (not an external OIDC provider like Dex). +# +# The test: +# 1. Creates a Kind cluster with the API server OIDC discovery endpoint exposed +# 2. Exports the Kind cluster's CA certificate +# 3. Deploys Zot with OIDC bearer authentication +# 4. Creates a test Pod with a projected ServiceAccount token +# 5. Verifies authentication succeeds with the token +# 6. Verifies authentication fails without the token +# +# Usage: +# ./kind-oidc-workload-identity.sh [OPTIONS] +# +# Options: +# --skip-setup Skip cluster creation, image building, and initial setup +# (assumes resources already exist from a previous run) +# --only-crane Only run crane e2e tests (tests 8-14) +# --only-curl Only run curl-based tests (tests 1-7) +# --keep-resources Don't clean up resources on exit (useful for debugging) +# --help Show this help message + +set -o errexit +set -o pipefail + +# Parse command line arguments +SKIP_SETUP=false +ONLY_CRANE=false +ONLY_CURL=false +KEEP_RESOURCES=false + +while [[ $# -gt 0 ]]; do + case $1 in + --skip-setup) + SKIP_SETUP=true + shift + ;; + --only-crane) + ONLY_CRANE=true + shift + ;; + --only-curl) + ONLY_CURL=true + shift + ;; + --keep-resources) + KEEP_RESOURCES=true + shift + ;; + --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 + +# Check prerequisites +check_prerequisites() { + local missing="" + for cmd in docker kubectl jq openssl curl git; do + if ! command -v "$cmd" &> /dev/null; then + missing="$missing $cmd" + fi + done + if [ -n "$missing" ]; then + echo "Error: Missing required tools:$missing" + exit 1 + fi +} + +check_prerequisites + +ROOT_DIR=$(git rev-parse --show-toplevel) +cd "${ROOT_DIR}" + +# Use project's kind if available, otherwise fall back to system kind +if [ -x "${ROOT_DIR}/hack/tools/bin/kind" ]; then + KIND="${ROOT_DIR}/hack/tools/bin/kind" +elif command -v kind &> /dev/null; then + KIND="kind" +else + echo "Error: kind not found. Install kind or run 'make ${ROOT_DIR}/hack/tools/bin/kind'" + exit 1 +fi + +CLUSTER_NAME="kind-oidc-wid" +ZOT_REG_NAME="zot-oidc-wid" +ZOT_PORT="5000" +TEST_NAMESPACE="oidc-test" +TEST_SA_NAME="test-workload" +AUDIENCE="zot-registry" + +# Pin image versions for reproducibility and to avoid Docker Hub rate limiting issues +# These versions should be updated periodically +# Note: BUSYBOX_IMAGE uses gcr.io to avoid Docker Hub rate limits for crane operations +CURL_IMAGE="curlimages/curl:8.5.0" +ALPINE_IMAGE="alpine:3.19" +BUSYBOX_IMAGE="gcr.io/google-containers/busybox:1.27" +KIND_NODE_IMAGE="kindest/node:v1.28.7" + +# 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" +} + +# Helper function to ensure a pod exists and is ready +# Usage: ensure_pod_ready +# Returns 0 if pod is ready, 1 if it couldn't be created/started +ensure_pod_ready() { + local pod_name="$1" + local namespace="$2" + local timeout="${3:-120}" + + if kubectl get pod "$pod_name" -n "$namespace" &>/dev/null; then + local pod_status + pod_status=$(kubectl get pod "$pod_name" -n "$namespace" -o jsonpath='{.status.phase}') + if [ "$pod_status" = "Running" ]; then + log_info "Pod '$pod_name' already exists and is running (reusing)" + return 0 + else + log_info "Pod '$pod_name' exists but status is '$pod_status', waiting..." + fi + else + return 1 # Pod doesn't exist, caller should create it + fi + + # Wait for pod to be ready + kubectl wait --for=condition=Ready "pod/$pod_name" -n "$namespace" --timeout="${timeout}s" +} + +cleanup() { + if [ "$KEEP_RESOURCES" = true ]; then + log_info "Keeping resources (--keep-resources specified)" + log_info "To clean up manually, run:" + log_info " ${KIND} delete cluster --name ${CLUSTER_NAME}" + log_info " docker rm -f ${ZOT_REG_NAME}" + return + fi + log_info "Cleaning up..." + "${KIND}" delete cluster --name "${CLUSTER_NAME}" 2>/dev/null || true + docker rm -f "${ZOT_REG_NAME}" 2>/dev/null || true + rm -f /tmp/kind-ca.pem /tmp/zot-oidc-config.json /tmp/test-token.txt 2>/dev/null || true +} + +trap cleanup EXIT + +# Set no_proxy if applicable +if [ -n "${no_proxy}" ]; then + log_info "Updating no_proxy env var" + export no_proxy="${no_proxy},${ZOT_REG_NAME}" + export NO_PROXY="${no_proxy}" +fi + +# Pre-pull images to avoid Docker Hub rate limiting issues in CI +# This is done early so failures are caught before cluster creation +prepull_images() { + log_info "Pre-pulling container images (helps avoid Docker Hub rate limiting)..." + local images=("${CURL_IMAGE}" "${ALPINE_IMAGE}" "${BUSYBOX_IMAGE}" "${KIND_NODE_IMAGE}") + for img in "${images[@]}"; do + log_info "Pulling ${img}..." + if ! docker pull "${img}" 2>/dev/null; then + log_warn "Failed to pull ${img} - will retry during test (may be rate limited)" + fi + done +} + +# Skip setup if requested +if [ "$SKIP_SETUP" = true ]; then + log_info "Skipping setup (--skip-setup specified)" + log_info "Using existing cluster '${CLUSTER_NAME}' and zot container '${ZOT_REG_NAME}'" + + # Verify resources exist + if ! "${KIND}" get clusters 2>/dev/null | grep -q "${CLUSTER_NAME}"; then + log_error "Cluster '${CLUSTER_NAME}' does not exist. Run without --skip-setup first." + exit 1 + fi + if ! docker ps --format '{{.Names}}' | grep -q "^${ZOT_REG_NAME}$"; then + log_error "Zot container '${ZOT_REG_NAME}' is not running. Run without --skip-setup first." + exit 1 + fi + + # Set kubectl context + kubectl config use-context "kind-${CLUSTER_NAME}" + + # Get the OIDC issuer URL + CONTROL_PLANE_CONTAINER="${CLUSTER_NAME}-control-plane" + OIDC_ISSUER="https://${CONTROL_PLANE_CONTAINER}:6443" +else + # Delete existing cluster if it exists + log_info "Cleaning up any existing resources..." + "${KIND}" delete cluster --name "${CLUSTER_NAME}" 2>/dev/null || true + docker rm -f "${ZOT_REG_NAME}" 2>/dev/null || true + + # Pre-pull images to avoid rate limiting + prepull_images + +# Create Kind cluster with custom configuration +# - Configure the ServiceAccount issuer to be accessible from zot (via docker network) +# - Add the container name as a SAN to the API server certificate +log_info "Creating Kind cluster '${CLUSTER_NAME}'..." +cat <:6443/system:serviceaccount::" +cat < /tmp/zot-oidc-config.json +{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/var/lib/zot" + }, + "http": { + "address": "0.0.0.0", + "port": "${ZOT_PORT}", + "auth": { + "bearer": { + "realm": "zot", + "service": "zot-registry", + "oidc": [ + { + "issuer": "${OIDC_ISSUER}", + "audiences": ["${AUDIENCE}"] + } + ] + } + }, + "accessControl": { + "repositories": { + "**": { + "policies": [ + { + "users": ["${OIDC_ISSUER}/system:serviceaccount:${TEST_NAMESPACE}:${TEST_SA_NAME}"], + "actions": ["read", "create", "update", "delete"] + } + ], + "defaultPolicy": [] + } + } + } + }, + "log": { + "level": "debug" + } +} +EOF + +log_info "Zot configuration:" +cat /tmp/zot-oidc-config.json + +# Run zot container connected to the kind network +log_info "Starting zot container..." +docker run -d \ + --name "${ZOT_REG_NAME}" \ + --network kind \ + -p "127.0.0.1:${ZOT_PORT}:${ZOT_PORT}" \ + -v /tmp/zot-oidc-config.json:/etc/zot/config.json:ro \ + -v /tmp/kind-ca.pem:/etc/zot/kind-ca.pem:ro \ + -e ZOT_BEARER_OIDC_TEST_CA_FILE=/etc/zot/kind-ca.pem \ + "${IMAGE_NAME}" \ + serve /etc/zot/config.json + +# Wait for zot to be ready +log_info "Waiting for zot to be ready..." +sleep 5 + +# Check zot logs +log_info "Zot container logs:" +docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + +# Get zot container IP on the kind network +ZOT_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${ZOT_REG_NAME}") +log_info "Zot container IP: ${ZOT_IP}" + +# Verify zot is running and responding +log_info "Checking zot health..." +for i in {1..30}; do + # zot should return 401 for unauthenticated requests when bearer auth is configured + HTTP_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${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 (HTTP $HTTP_RESPONSE)" + docker logs "${ZOT_REG_NAME}" + exit 1 + fi + sleep 1 +done + +fi # End of setup section (skip-setup conditional) + +# Create test namespace and ServiceAccount +log_info "Creating test namespace and ServiceAccount..." +kubectl create namespace "${TEST_NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - +kubectl create serviceaccount "${TEST_SA_NAME}" -n "${TEST_NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - + +# Create a test Pod with projected ServiceAccount token (or reuse existing) +# Using a lightweight image with wget/curl for testing +if ! ensure_pod_ready "oidc-test-pod" "${TEST_NAMESPACE}" 120; then + log_info "Creating test Pod with projected ServiceAccount token..." + cat < /tmp/test-token.txt +log_info "Token claims (decoded):" +# Decode the JWT payload (second part, base64url encoded) +PAYLOAD=$(cat /tmp/test-token.txt | cut -d'.' -f2) +# Add padding if needed and decode +PAYLOAD_PADDED="${PAYLOAD}$(printf '%*s' $((4 - ${#PAYLOAD} % 4)) | tr ' ' '=')" +echo "${PAYLOAD_PADDED}" | base64 -d 2>/dev/null | jq . || log_warn "Could not decode token (may need jq)" + +# ============================================================================= +# CURL-BASED TESTS (Tests 1-7) +# ============================================================================= +if [ "$ONLY_CRANE" = true ]; then + log_info "Skipping curl-based tests (--only-oras specified)" +else + +# Test 1: Verify PUSH fails without token +log_info "TEST 1: Verifying push (blob upload) fails without token..." +HTTP_CODE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod -- \ + curl -s -o /dev/null -w "%{http_code}" -X POST "http://${ZOT_REG_NAME}:${ZOT_PORT}/v2/test-repo/blobs/uploads/" 2>/dev/null || echo "000") + +if [ "$HTTP_CODE" = "401" ]; then + log_info "TEST 1 PASSED: Push correctly rejected without token (HTTP $HTTP_CODE)" +else + log_error "TEST 1 FAILED: Expected 401, got HTTP $HTTP_CODE" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +# Test 2: Verify authentication SUCCEEDS with token +log_info "TEST 2: Verifying authentication succeeds with token..." +RESPONSE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod -- \ + sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -w "\n%{http_code}" -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/_catalog"') + +HTTP_CODE=$(echo "$RESPONSE" | tail -1) +BODY=$(echo "$RESPONSE" | head -n -1) + +if [ "$HTTP_CODE" = "200" ]; then + log_info "TEST 2 PASSED: Authentication succeeded with token (HTTP $HTTP_CODE)" + log_info "Response body: $BODY" +else + log_error "TEST 2 FAILED: Authentication failed with valid token (HTTP $HTTP_CODE)" + log_error "Response: $BODY" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -50 + exit 1 +fi + +# Test 3: Initiate a blob upload (tests write permissions) +log_info "TEST 3: Testing write permissions (initiate blob upload)..." +RESPONSE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod -- \ + sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -w "\n%{http_code}" -X POST -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/test-repo/blobs/uploads/"') + +HTTP_CODE=$(echo "$RESPONSE" | tail -1) + +if [ "$HTTP_CODE" = "202" ]; then + log_info "TEST 3 PASSED: Write operation succeeded (HTTP $HTTP_CODE - upload initiated)" +else + log_error "TEST 3 FAILED: Write operation failed (HTTP $HTTP_CODE)" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +# Test 4: List the catalog to verify repository was created +log_info "TEST 4: Listing catalog to verify repository exists..." +CATALOG=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod -- \ + sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/_catalog"') +log_info "Catalog: ${CATALOG}" + +if echo "$CATALOG" | grep -q "test-repo"; then + log_info "TEST 4 PASSED: Repository 'test-repo' found in catalog" +else + log_warn "TEST 4: Repository may not appear immediately in catalog (this is expected)" +fi + +# Test 5: Verify wrong audience token fails +log_info "TEST 5: Verifying wrong audience token fails..." + +# Create another pod with a different audience (or reuse existing) +if ! ensure_pod_ready "oidc-test-pod-wrong-aud" "${TEST_NAMESPACE}" 60; then + cat </dev/null || echo "000") + +if [ "$HTTP_CODE" = "401" ]; then + log_info "TEST 5 PASSED: Wrong audience token correctly rejected (HTTP $HTTP_CODE)" +else + log_error "TEST 5 FAILED: Expected 401 for wrong audience, got HTTP $HTTP_CODE" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +# Test 6: Verify different ServiceAccount with correct audience is authenticated +# Note: Currently, OIDC bearer auth only performs authentication, not repository-level authorization. +# The BaseAuthzHandler in zot bypasses authorization checks for bearer auth. +# This test verifies that a different SA with the correct audience CAN authenticate (proving OIDC works) +# but has NO permissions because it's not in the accessControl config. +# The username is derived from the token and used for authorization checks. +log_info "TEST 6: Verifying different ServiceAccount authenticates but has NO permissions..." + +# Create a different ServiceAccount +kubectl create serviceaccount other-sa -n "${TEST_NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - + +# Create pod with the other ServiceAccount (or reuse existing) +if ! ensure_pod_ready "oidc-test-pod-other-sa" "${TEST_NAMESPACE}" 60; then + cat </dev/null || echo "{}") + +if echo "$CATALOG_RESPONSE" | grep -q '"repositories":\[\]'; then + log_info "TEST 6 PASSED: Other ServiceAccount authenticated but has NO permissions (empty catalog)" + log_info " The username '${OIDC_ISSUER}/system:serviceaccount:${TEST_NAMESPACE}:other-sa' was extracted from the token." + log_info " Authorization is enforced via accessControl config." +else + log_error "TEST 6 FAILED: Expected empty catalog for other-sa (not in config)" + log_error "Got: $CATALOG_RESPONSE" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +# ============================================================================= +# TEST 7: Verify other-sa gets 403 when trying to write (authorization enforced) +# ============================================================================= +log_info "TEST 7: Verifying other-sa gets 403 Forbidden when trying to write..." + +HTTP_CODE=$(kubectl exec -n "${TEST_NAMESPACE}" oidc-test-pod-other-sa -- \ + sh -c 'TOKEN=$(cat /var/run/secrets/tokens/zot-token); curl -s -o /dev/null -w "%{http_code}" -X POST -H "Authorization: Bearer $TOKEN" "http://${ZOT_REGISTRY}/v2/unauthorized-repo/blobs/uploads/"' 2>/dev/null || echo "000") + +if [ "$HTTP_CODE" = "403" ]; then + log_info "TEST 7 PASSED: Other ServiceAccount correctly rejected for write (HTTP 403)" +else + log_error "TEST 7 FAILED: Expected 403 for write operation, got HTTP $HTTP_CODE" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +fi # End of curl-based tests conditional + +# ============================================================================= +# E2E Tests using Crane CLI for real OCI image operations +# ============================================================================= +# NOTE: Crane (from go-containerregistry) properly supports the `registryToken` +# field in Docker config, which sends the token directly as a Bearer header. +# This is compatible with zot's OIDC bearer authentication. +# +# Other tools like oras and skopeo do NOT properly support this - they expect +# the token service authentication flow (exchanging credentials with a token +# endpoint) which is different from direct bearer token authentication. +# ============================================================================= +if [ "$ONLY_CURL" = true ]; then + log_info "Skipping crane e2e tests (--only-curl specified)" +else + +# Create a Pod with crane CLI for e2e artifact push/pull tests (or reuse existing) +if ! ensure_pod_ready "crane-test-pod" "${TEST_NAMESPACE}" 120; then + log_info "Creating crane test Pod for e2e artifact operations..." + cat < ~/.docker/config.json << EOFCONFIG +{ + "auths": { + "$ZOT_REGISTRY": { + "registryToken": "$TOKEN" + } + } +} +EOFCONFIG + ' +} + +# Helper function to remove Docker config (no auth) +remove_crane_auth() { + kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- sh -c ' + rm -f ~/.docker/config.json + rm -f /tmp/auth.json + rm -rf ~/.config/containers + ' 2>/dev/null || true +} + +# ============================================================================= +# TEST 8: Copy OCI image using crane WITH auth (should SUCCEED) +# Note: This runs first to populate zot, so subsequent tests don't need Docker Hub +# We check if the image already exists to avoid Docker Hub rate limiting on reruns +# ============================================================================= +log_info "TEST 8: Copying OCI image using crane WITH auth (should succeed)..." +setup_crane_auth + +# Check if image already exists in zot from a previous run +IMAGE_EXISTS=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ + sh -c 'crane manifest --insecure $ZOT_REGISTRY/crane-test:v1 2>&1 && echo EXISTS' || true) + +if echo "$IMAGE_EXISTS" | grep -q "EXISTS"; then + log_info "Image crane-test:v1 already exists in zot, skipping Docker Hub pull" + # Verify we can still access it with auth (do a copy to v1-test to verify write works) + PUSH_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ + sh -c 'crane copy --insecure $ZOT_REGISTRY/crane-test:v1 $ZOT_REGISTRY/crane-test:v1-test 2>&1') || true + if echo "$PUSH_OUTPUT" | grep -qiE "pushed|existing|copied|digest"; then + log_info "TEST 8 PASSED: crane copy within zot succeeded with auth" + log_info "Push output: $(echo "$PUSH_OUTPUT" | tail -2)" + else + log_error "TEST 8 FAILED: crane copy within zot failed" + log_error "Output: $PUSH_OUTPUT" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -50 + exit 1 + fi +else + # Image doesn't exist, need to pull from Docker Hub + log_info "Image not found in zot, pulling from Docker Hub..." + PUSH_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ + sh -c "crane copy --insecure ${BUSYBOX_IMAGE} \$ZOT_REGISTRY/crane-test:v1 2>&1") || true + + if echo "$PUSH_OUTPUT" | grep -qiE "pushed|existing|crane-test:v1.*digest|copied"; then + log_info "TEST 8 PASSED: crane copy from Docker Hub succeeded with auth" + log_info "Push output: $(echo "$PUSH_OUTPUT" | tail -3)" + else + log_error "TEST 8 FAILED: crane copy failed with valid auth" + log_error "Output: $PUSH_OUTPUT" + log_error "Note: If you see rate limit errors, this may be due to Docker Hub throttling" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -50 + exit 1 + fi +fi + +# Verify the image was pushed by listing tags +log_info "Verifying pushed image (listing tags)..." +TAGS=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ + sh -c 'crane ls --insecure $ZOT_REGISTRY/crane-test 2>&1') || true +log_info "Tags for crane-test: $TAGS" + +if echo "$TAGS" | grep -q "v1"; then + log_info "Verified: Tag 'v1' found in crane-test repository" +else + log_warn "Warning: Tag 'v1' not found in crane-test repository" +fi + +# ============================================================================= +# TEST 9: Copy OCI image using crane WITHOUT auth (should FAIL) +# Note: Uses zot-to-zot copy to avoid Docker Hub rate limiting +# ============================================================================= +log_info "TEST 9: Copying OCI image using crane WITHOUT auth (should fail)..." +remove_crane_auth + +COPY_NO_AUTH_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ + sh -c 'crane copy --insecure $ZOT_REGISTRY/crane-test:v1 $ZOT_REGISTRY/crane-test:v2 2>&1' || true) + +if echo "$COPY_NO_AUTH_OUTPUT" | grep -qiE "401|unauthorized|UNAUTHORIZED"; then + log_info "TEST 9 PASSED: crane copy correctly rejected without auth (401)" + log_info "Output: $(echo "$COPY_NO_AUTH_OUTPUT" | tail -2)" +else + log_error "TEST 9 FAILED: Expected 401 authentication failure" + log_error "Output: $COPY_NO_AUTH_OUTPUT" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +# ============================================================================= +# TEST 10: List tags using crane WITHOUT auth (should FAIL) +# ============================================================================= +log_info "TEST 10: Listing tags using crane WITHOUT auth (should fail)..." + +PULL_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ + sh -c 'crane ls --insecure $ZOT_REGISTRY/crane-test 2>&1' || true) + +if echo "$PULL_OUTPUT" | grep -qiE "401|unauthorized|authentication|UNAUTHORIZED"; then + log_info "TEST 10 PASSED: crane ls correctly rejected without auth" + log_info "Output: $(echo "$PULL_OUTPUT" | tail -2)" +else + log_error "TEST 10 FAILED: Expected authentication failure" + log_error "Output: $PULL_OUTPUT" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +# ============================================================================= +# TEST 11: Pull manifest using crane WITH auth (should SUCCEED) +# ============================================================================= +log_info "TEST 11: Pulling manifest using crane WITH auth (should succeed)..." +setup_crane_auth + +PULL_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-test-pod -- \ + sh -c 'crane manifest --insecure $ZOT_REGISTRY/crane-test:v1 2>&1') || true + +if echo "$PULL_OUTPUT" | grep -qiE "schemaVersion|mediaType|manifests"; then + log_info "TEST 11 PASSED: crane manifest succeeded with auth" + log_info "Manifest preview: $(echo "$PULL_OUTPUT" | head -5)" +else + log_error "TEST 11 FAILED: crane manifest failed with valid auth" + log_error "Output: $PULL_OUTPUT" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -50 + exit 1 +fi + +# ============================================================================= +# CRANE TESTS FOR other-sa (NOT in accessControl config) +# These tests verify that authorization is enforced for real OCI operations +# ============================================================================= + +# Create crane pod for other-sa (or reuse existing) +if ! ensure_pod_ready "crane-other-sa-pod" "${TEST_NAMESPACE}" 120; then + log_info "Creating crane pod for other-sa..." + cat < ~/.docker/config.json << EOFCONFIG +{ + "auths": { + "$ZOT_REGISTRY": { + "registryToken": "$TOKEN" + } + } +} +EOFCONFIG + ' +} + +# ============================================================================= +# TEST 12: Copy OCI image using crane with other-sa (should FAIL with 403) +# Note: Uses zot-to-zot copy to avoid Docker Hub rate limiting +# ============================================================================= +log_info "TEST 12: Copying OCI image using crane with other-sa (should fail with 403)..." +setup_other_sa_crane_auth + +PUSH_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-other-sa-pod -- \ + sh -c 'crane copy --insecure $ZOT_REGISTRY/crane-test:v1 $ZOT_REGISTRY/other-sa-crane-test:v1 2>&1' || true) + +if echo "$PUSH_OUTPUT" | grep -qiE "403|forbidden|denied"; then + log_info "TEST 12 PASSED: crane copy correctly rejected for other-sa (403 Forbidden)" + log_info "Output: $(echo "$PUSH_OUTPUT" | tail -2)" +else + log_error "TEST 12 FAILED: Expected 403 for other-sa push" + log_error "Output: $PUSH_OUTPUT" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +# ============================================================================= +# TEST 13: List tags using crane with other-sa (should FAIL with 403) +# ============================================================================= +log_info "TEST 13: Listing tags using crane with other-sa (should fail with 403)..." + +LIST_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-other-sa-pod -- \ + sh -c 'crane ls --insecure $ZOT_REGISTRY/crane-test 2>&1' || true) + +if echo "$LIST_OUTPUT" | grep -qiE "403|forbidden|unauthorized|denied"; then + log_info "TEST 13 PASSED: crane ls correctly rejected for other-sa (access denied)" + log_info "Output: $(echo "$LIST_OUTPUT" | tail -2)" +else + log_error "TEST 13 FAILED: Expected 403 for other-sa list" + log_error "Output: $LIST_OUTPUT" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +# ============================================================================= +# TEST 14: Crane operation with NO token (should FAIL with 401, not 403) +# This verifies the difference between authentication failure (401) and +# authorization failure (403) +# ============================================================================= +log_info "TEST 14: Crane operation with NO token (should fail with 401)..." + +# Remove auth config from other-sa pod +kubectl exec -n "${TEST_NAMESPACE}" crane-other-sa-pod -- sh -c 'rm -f ~/.docker/config.json' 2>/dev/null || true + +NO_TOKEN_OUTPUT=$(kubectl exec -n "${TEST_NAMESPACE}" crane-other-sa-pod -- \ + sh -c 'crane ls --insecure $ZOT_REGISTRY/crane-test 2>&1' || true) + +if echo "$NO_TOKEN_OUTPUT" | grep -qiE "401|unauthorized"; then + log_info "TEST 14 PASSED: No token correctly returns 401 Unauthorized" + log_info "Output: $(echo "$NO_TOKEN_OUTPUT" | tail -2)" +else + log_error "TEST 14 FAILED: Expected 401 for no token, got different error" + log_error "Output: $NO_TOKEN_OUTPUT" + docker logs "${ZOT_REG_NAME}" 2>&1 | tail -30 + exit 1 +fi + +fi # End of crane tests conditional + +# Print final zot logs for debugging +log_info "Final zot logs:" +docker logs "${ZOT_REG_NAME}" 2>&1 | tail -50 + +log_info "==========================================" +if [ "$ONLY_CRANE" = true ]; then + log_info "Crane e2e tests (8-14) PASSED!" +elif [ "$ONLY_CURL" = true ]; then + log_info "Curl-based tests (1-7) PASSED!" +else + log_info "All OIDC Workload Identity tests PASSED!" +fi +log_info "==========================================" +log_info "" +log_info "Iteration tips:" +log_info " --skip-setup Skip cluster/image/zot setup (reuse existing)" +log_info " --only-crane Run only crane tests (8-14)" +log_info " --only-curl Run only curl tests (1-7)" +log_info " --keep-resources Keep cluster/zot running after exit" diff --git a/go.mod b/go.mod index b6ed0511..e018df59 100644 --- a/go.mod +++ b/go.mod @@ -21,17 +21,20 @@ require ( github.com/briandowns/spinner v1.23.2 github.com/cloudevents/sdk-go/protocol/nats/v2 v2.16.2 github.com/cloudevents/sdk-go/v2 v2.16.2 + github.com/coreos/go-oidc/v3 v3.17.0 github.com/dchest/siphash v1.2.3 github.com/didip/tollbooth/v7 v7.0.2 github.com/distribution/distribution/v3 v3.0.0 github.com/dustin/go-humanize v1.0.1 github.com/fsnotify/fsnotify v1.9.0 + github.com/go-jose/go-jose/v4 v4.1.3 github.com/go-ldap/ldap/v3 v3.4.12 github.com/go-redis/redismock/v9 v9.2.0 github.com/go-redsync/redsync/v4 v4.15.0 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/gofrs/uuid v4.4.0+incompatible github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/cel-go v0.26.1 github.com/google/go-containerregistry v0.20.7 github.com/google/go-github/v62 v62.0.0 github.com/google/uuid v1.6.0 @@ -49,6 +52,7 @@ require ( github.com/notaryproject/notation-core-go v1.3.0 github.com/notaryproject/notation-go v1.3.2 github.com/olekukonko/tablewriter v1.1.3 + github.com/onsi/gomega v1.38.2 github.com/opencontainers/distribution-spec/specs-go v0.0.0-20250123160558-a139cc423184 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 @@ -154,6 +158,7 @@ require ( github.com/aliyun/credentials-go v1.3.6 // indirect github.com/anchore/go-struct-converter v0.0.0-20230627203149-c72ef8859ca9 // indirect github.com/antithesishq/antithesis-sdk-go v0.5.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apparentlymart/go-cidr v1.1.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aquasecurity/go-gem-version v0.0.0-20201115065557-8eed6fe000ce // indirect @@ -227,7 +232,6 @@ require ( github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect github.com/containerd/ttrpc v1.2.7 // indirect github.com/containerd/typeurl/v2 v2.2.3 // indirect - github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/cyphar/filepath-securejoin v0.6.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -266,7 +270,6 @@ require ( github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect - github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.24.1 // indirect @@ -467,6 +470,7 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/swaggo/files v1.0.1 // indirect diff --git a/go.sum b/go.sum index 88ee7993..ff768b09 100644 --- a/go.sum +++ b/go.sum @@ -814,6 +814,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antithesishq/antithesis-sdk-go v0.5.0 h1:cudCFF83pDDANcXFzkQPUHHedfnnIbUO3JMr9fqwFJs= github.com/antithesishq/antithesis-sdk-go v0.5.0/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= @@ -1377,6 +1379,8 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A= github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs= github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= @@ -2068,6 +2072,8 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= diff --git a/pkg/api/authn.go b/pkg/api/authn.go index 04b6f58b..ef9ec65a 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -485,18 +485,35 @@ func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc { // Get auth config safely authConfig := ctlr.Config.CopyAuthConfig() - // although the configuration option is called 'cert', this function will also parse a public key directly - // see https://github.com/project-zot/zot/issues/3173 for info - publicKey, err := loadPublicKeyFromFile(authConfig.Bearer.Cert) - if err != nil { - ctlr.Log.Panic().Err(err).Msg("failed to load public key for bearer authentication") + // Initialize authorizers based on configuration + var traditionalAuthorizer *BearerAuthorizer + + var oidcAuthorizer *OIDCBearerAuthorizer + + // Traditional bearer auth with public key/certificate + if authConfig.Bearer.Cert != "" { + // although the configuration option is called 'cert', this function will also parse a public key directly + // see https://github.com/project-zot/zot/issues/3173 for info + publicKey, err := loadPublicKeyFromFile(authConfig.Bearer.Cert) + if err != nil { + ctlr.Log.Panic().Err(err).Msg("failed to load public key for bearer authentication") + } + + traditionalAuthorizer = NewBearerAuthorizer( + authConfig.Bearer.Realm, + authConfig.Bearer.Service, + publicKey, + ) } - authorizer := NewBearerAuthorizer( - authConfig.Bearer.Realm, - authConfig.Bearer.Service, - publicKey, - ) + // OIDC bearer auth for workload identity + if len(authConfig.Bearer.OIDC) > 0 { + var err error + oidcAuthorizer, err = NewOIDCBearerAuthorizer(authConfig.Bearer.OIDC, ctlr.Log) + if err != nil { + ctlr.Log.Panic().Err(err).Msg("failed to initialize OIDC bearer authorizer") + } + } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { @@ -548,27 +565,85 @@ func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc { } } - err := authorizer.Authorize(header, requestedAccess) - if err != nil { - var challenge *AuthChallengeError - if errors.As(err, &challenge) { - ctlr.Log.Debug().Err(challenge).Msg("bearer token authorization failed") + // Try OIDC authentication first if configured + var username string + + var groups []string + + if oidcAuthorizer != nil { + var err error + + var authenticated bool + + username, groups, authenticated, err = oidcAuthorizer.AuthenticateRequest(request.Context(), header) + if err == nil && authenticated { + // OIDC authentication succeeded + ctlr.Log.Debug().Str("username", username).Msg("the OIDC bearer authentication was successful") + + // Set user context for authorization + userAc := reqCtx.NewUserAccessControl() + userAc.SetUsername(username) + userAc.AddGroups(groups) + userAc.SaveOnRequest(request) + + // Update user groups in MetaDB if available + if ctlr.MetaDB != nil { + if err := ctlr.MetaDB.SetUserGroups(request.Context(), groups); err != nil { + ctlr.Log.Error().Err(err).Str("username", username).Msg("failed to update user profile") + response.WriteHeader(http.StatusInternalServerError) + + return + } + } + + // Use BEARER_OIDC to enable authorization via accessControl config. + // Unlike traditional bearer tokens (which contain 'access' claims with permissions), + // OIDC tokens contain identity only, so authorization must come from the config. + amCtx := acCtrlr.getAuthnMiddlewareContext(BEARER_OIDC, request) + next.ServeHTTP(response, request.WithContext(amCtx)) //nolint:contextcheck + + return + } + } + + // Fall back to traditional bearer token auth if OIDC didn't succeed + if traditionalAuthorizer != nil { + err := traditionalAuthorizer.Authorize(header, requestedAccess) + if err != nil { + var challenge *AuthChallengeError + if errors.As(err, &challenge) { + ctlr.Log.Debug().Err(challenge).Msg("bearer token authorization failed") + response.Header().Set("Content-Type", "application/json") + response.Header().Set("WWW-Authenticate", challenge.Header()) + zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNAUTHORIZED)) + + return + } + + ctlr.Log.Error().Err(err).Msg("failed to parse Authorization header") response.Header().Set("Content-Type", "application/json") - response.Header().Set("WWW-Authenticate", challenge.Header()) - zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNAUTHORIZED)) + zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNSUPPORTED)) return } - ctlr.Log.Error().Err(err).Msg("failed to parse Authorization header") - response.Header().Set("Content-Type", "application/json") - zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNSUPPORTED)) + amCtx := acCtrlr.getAuthnMiddlewareContext(BEARER, request) + next.ServeHTTP(response, request.WithContext(amCtx)) //nolint:contextcheck return } - amCtx := acCtrlr.getAuthnMiddlewareContext(BEARER, request) - next.ServeHTTP(response, request.WithContext(amCtx)) //nolint:contextcheck + // No authentication succeeded + if isAuthorizationHeaderEmpty(request) { + // No bearer token provided and no authentication method configured + ctlr.Log.Debug().Msg("no bearer token provided") + } else { + // Bearer token provided but authentication failed + ctlr.Log.Error().Msg("failed to authenticate with bearer token") + } + + response.Header().Set("Content-Type", "application/json") + zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNAUTHORIZED)) }) } } diff --git a/pkg/api/authn_test.go b/pkg/api/authn_test.go index 88cac79f..ee1bff7b 100644 --- a/pkg/api/authn_test.go +++ b/pkg/api/authn_test.go @@ -4,6 +4,8 @@ package api_test import ( "context" + "crypto/rand" + "crypto/rsa" "crypto/tls" "crypto/x509" "encoding/base64" @@ -11,6 +13,7 @@ import ( "encoding/pem" "errors" "io/fs" + "maps" "net/http" "net/http/httptest" "os" @@ -48,6 +51,8 @@ import ( const ( sessionCookieName = "session" userCookieName = "user" + testSubject = "test-user" + testKeyID = "test-key-id" ) type ( @@ -1126,6 +1131,578 @@ func TestMultipleAuthorizationHeaders(t *testing.T) { }) } +func TestBearerOIDCWorkloadIdentity(t *testing.T) { + Convey("Test bearer auth with OIDC workload identity", t, func() { + // Generate test keys for mock OIDC server + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + So(err, ShouldBeNil) + pubKey := &privKey.PublicKey + + // Start mock OIDC server + server := mockWorkloadOIDCServer(t, pubKey) + defer server.Close() + + issuer := server.URL + audience := "test-zot" + + Convey("OIDC authentication success", func() { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + }}, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Create a valid OIDC token + token, err := createWorkloadOIDCToken(privKey, issuer, audience, nil) + So(err, ShouldBeNil) + + // Test successful authentication + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/v2/_catalog", nil) + So(err, ShouldBeNil) + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + So(resp.StatusCode, ShouldEqual, http.StatusOK) + }) + + Convey("OIDC authentication success with groups", func() { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + ClaimMapping: &config.CELClaimValidationAndMapping{ + Groups: "claims.groups", + }, + }}, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Create a valid OIDC token with groups + token, err := createWorkloadOIDCToken(privKey, issuer, audience, map[string]any{ + "groups": []string{"admin", "developers"}, + }) + So(err, ShouldBeNil) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/v2/_catalog", nil) + So(err, ShouldBeNil) + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + So(resp.StatusCode, ShouldEqual, http.StatusOK) + }) + + Convey("OIDC authentication fails with MetaDB error", func() { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + }}, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartServer() + defer cm.StopServer() + test.WaitTillServerReady(baseURL) + + // Replace MetaDB with a mock that returns an error + ctlr.MetaDB = mocks.MetaDBMock{ + SetUserGroupsFn: func(ctx context.Context, groups []string) error { + return ErrUnexpectedError + }, + } + + // Create a valid OIDC token + token, err := createWorkloadOIDCToken(privKey, issuer, audience, nil) + So(err, ShouldBeNil) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/v2/_catalog", nil) + So(err, ShouldBeNil) + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + // Should fail with internal server error due to MetaDB failure + So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) + }) + + Convey("OIDC authentication fails, falls back to traditional bearer auth", func() { + tempDir := t.TempDir() + + // Generate certificate for traditional bearer auth + caCert, caKey, err := tlsutils.GenerateCACert() + So(err, ShouldBeNil) + + serverCertPath := path.Join(tempDir, "server.cert") + serverKeyPath := path.Join(tempDir, "server.key") + opts := &tlsutils.CertificateOptions{ + Hostname: "localhost", + } + err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, opts) + So(err, ShouldBeNil) + + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + Cert: serverCertPath, + Realm: "test-realm", + Service: "test-service", + OIDC: []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + }}, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Load the private key to sign traditional bearer token + keyBytes, err := os.ReadFile(serverKeyPath) + So(err, ShouldBeNil) + + keyBlock, _ := pem.Decode(keyBytes) + So(keyBlock, ShouldNotBeNil) + + privateKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + So(err, ShouldBeNil) + + // Create a traditional bearer token (not OIDC) + claims := &api.ClaimsWithAccess{ + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + Access: []api.ResourceAccess{ + { + Type: "repository", + Name: "", + Actions: []string{"pull"}, + }, + }, + } + + traditionalToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + traditionalTokenString, err := traditionalToken.SignedString(privateKey) + So(err, ShouldBeNil) + + // Request with traditional bearer token should succeed via fallback + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/v2/_catalog", nil) + So(err, ShouldBeNil) + req.Header.Set("Authorization", "Bearer "+traditionalTokenString) + + client := &http.Client{} + resp, err := client.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + So(resp.StatusCode, ShouldEqual, http.StatusOK) + }) + + Convey("OIDC authentication with invalid token", func() { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + }}, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Test with invalid token + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/v2/_catalog", nil) + So(err, ShouldBeNil) + req.Header.Set("Authorization", "Bearer invalid-token") + + client := &http.Client{} + resp, err := client.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized) + }) + + Convey("OIDC authentication with no token provided", func() { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + }}, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Test without any authorization header + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/v2/_catalog", nil) + So(err, ShouldBeNil) + + client := &http.Client{} + resp, err := client.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized) + }) + + Convey("OIDC authentication with wrong audience", func() { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + }}, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Create a token with wrong audience + token, err := createWorkloadOIDCToken(privKey, issuer, "wrong-audience", nil) + So(err, ShouldBeNil) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/v2/_catalog", nil) + So(err, ShouldBeNil) + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized) + }) + + Convey("OIDC fails, traditional bearer auth with insufficient scope returns challenge", func() { + tempDir := t.TempDir() + + // Generate certificate for traditional bearer auth + caCert, caKey, err := tlsutils.GenerateCACert() + So(err, ShouldBeNil) + + serverCertPath := path.Join(tempDir, "server.cert") + serverKeyPath := path.Join(tempDir, "server.key") + opts := &tlsutils.CertificateOptions{ + Hostname: "localhost", + } + err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, opts) + So(err, ShouldBeNil) + + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + Cert: serverCertPath, + Realm: "test-realm", + Service: "test-service", + OIDC: []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + }}, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Load the private key to sign traditional bearer token + keyBytes, err := os.ReadFile(serverKeyPath) + So(err, ShouldBeNil) + + keyBlock, _ := pem.Decode(keyBytes) + So(keyBlock, ShouldNotBeNil) + + privateKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + So(err, ShouldBeNil) + + // Create a traditional bearer token with access to different repository (insufficient scope) + claims := &api.ClaimsWithAccess{ + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + Access: []api.ResourceAccess{ + { + Type: "repository", + Name: "other-repo", // Different repo than what we're accessing + Actions: []string{"pull"}, + }, + }, + } + + traditionalToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + traditionalTokenString, err := traditionalToken.SignedString(privateKey) + So(err, ShouldBeNil) + + // Request access to a different repository than what the token allows + // This should fail with AuthChallengeError (insufficient scope) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, baseURL+"/v2/testrepo/tags/list", nil) + So(err, ShouldBeNil) + req.Header.Set("Authorization", "Bearer "+traditionalTokenString) + + client := &http.Client{} + resp, err := client.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + // Should get 401 Unauthorized with WWW-Authenticate challenge header + So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized) + So(resp.Header.Get("WWW-Authenticate"), ShouldNotBeEmpty) + }) + + Convey("OIDC authentication with OPTIONS method", func() { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + }}, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Test OPTIONS method - should be allowed without authentication + req, err := http.NewRequestWithContext(context.Background(), http.MethodOptions, baseURL+"/v2/_catalog", nil) + So(err, ShouldBeNil) + + client := &http.Client{} + resp, err := client.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + // OPTIONS requests should be allowed without authentication + So(resp.StatusCode, ShouldEqual, http.StatusNoContent) + }) + + Convey("OIDC authentication with push action", func() { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + }}, + }, + } + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(port) + defer cm.StopServer() + + // Create a valid OIDC token + token, err := createWorkloadOIDCToken(privKey, issuer, audience, nil) + So(err, ShouldBeNil) + + // Test POST method which triggers push action + uploadURL := baseURL + "/v2/testrepo/blobs/uploads/" + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, uploadURL, nil) + So(err, ShouldBeNil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/octet-stream") + + client := &http.Client{} + resp, err := client.Do(req) + So(err, ShouldBeNil) + defer resp.Body.Close() + + // Should be able to authenticate, but may fail with 403 due to no write access configured + // The key is that authentication succeeded (not 401) + So(resp.StatusCode, ShouldNotEqual, http.StatusUnauthorized) + }) + }) +} + +// mockWorkloadOIDCServer creates a mock OIDC provider server for workload identity testing. +func mockWorkloadOIDCServer(t *testing.T, pubKey *rsa.PublicKey) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + + // OpenID configuration endpoint + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + oidcConfig := map[string]any{ + "issuer": "http://" + r.Host, + "jwks_uri": "http://" + r.Host + "/jwks", + } + + _ = json.NewEncoder(w).Encode(oidcConfig) + }) + + // JWKS endpoint + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Calculate modulus and exponent for JWK + nBytes := pubKey.N.Bytes() + eBytes := make([]byte, 4) + eBytes[0] = byte(pubKey.E >> 24) + eBytes[1] = byte(pubKey.E >> 16) + eBytes[2] = byte(pubKey.E >> 8) + eBytes[3] = byte(pubKey.E) + + // Trim leading zeros from exponent + for len(eBytes) > 1 && eBytes[0] == 0 { + eBytes = eBytes[1:] + } + + jwks := map[string]any{ + "keys": []map[string]any{ + { + "kty": "RSA", + "kid": testKeyID, + "alg": "RS256", + "use": "sig", + "n": base64.RawURLEncoding.EncodeToString(nBytes), + "e": base64.RawURLEncoding.EncodeToString(eBytes), + }, + }, + } + + _ = json.NewEncoder(w).Encode(jwks) + }) + + return httptest.NewServer(mux) +} + +// createWorkloadOIDCToken creates a test OIDC ID token for workload identity testing. +func createWorkloadOIDCToken(privKey *rsa.PrivateKey, issuer, audience string, + extraClaims map[string]any, +) (string, error) { + now := time.Now() + + tokenClaims := jwt.MapClaims{ + "iss": issuer, + "aud": []string{audience}, + "sub": testSubject, + "exp": now.Add(time.Hour).Unix(), + "iat": now.Unix(), + } + + // Add extra claims + maps.Copy(tokenClaims, extraClaims) + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, tokenClaims) + token.Header["kid"] = testKeyID + + return token.SignedString(privKey) +} + func TestAPIKeysOpenDBError(t *testing.T) { Convey("Test API keys - unable to create database", t, func() { conf := config.New() diff --git a/pkg/api/authz.go b/pkg/api/authz.go index fba44f6d..9d28034c 100644 --- a/pkg/api/authz.go +++ b/pkg/api/authz.go @@ -17,9 +17,10 @@ import ( ) const ( - BASIC = "Basic" - BEARER = "Bearer" - OPENID = "OpenID" + BASIC = "Basic" + BEARER = "Bearer" + BEARER_OIDC = "BearerOIDC" // OIDC bearer tokens use accessControl config for authorization + OPENID = "OpenID" ) func AuthzFilterFunc(userAc *reqCtx.UserAccessControl) storageTypes.FilterRepoFunc { @@ -264,7 +265,8 @@ func BaseAuthzHandler(ctlr *Controller) mux.MiddlewareFunc { return } - // request comes from bearer authn, bypass it + // request comes from bearer authn, bypass it. note: we don't bypass for BEARER_OIDC + // tokens since they use accessControl config for authorization authnMwCtx, err := reqCtx.GetAuthnMiddlewareContext(request.Context()) if err != nil || (authnMwCtx != nil && authnMwCtx.AuthnType == BEARER) { next.ServeHTTP(response, request) @@ -311,7 +313,8 @@ func DistSpecAuthzHandler(ctlr *Controller) mux.MiddlewareFunc { return } - // request comes from bearer authn, bypass it + // request comes from bearer authn, bypass it. note: we don't bypass for BEARER_OIDC + // tokens since they use accessControl config for authorization authnMwCtx, err := reqCtx.GetAuthnMiddlewareContext(request.Context()) if err != nil || (authnMwCtx != nil && authnMwCtx.AuthnType == BEARER) { next.ServeHTTP(response, request) diff --git a/pkg/api/bearer.go b/pkg/api/bearer.go index c2f9d669..5fad0470 100644 --- a/pkg/api/bearer.go +++ b/pkg/api/bearer.go @@ -65,8 +65,8 @@ type BearerAuthorizer struct { key crypto.PublicKey } -func NewBearerAuthorizer(realm string, service string, key crypto.PublicKey) BearerAuthorizer { - return BearerAuthorizer{ +func NewBearerAuthorizer(realm string, service string, key crypto.PublicKey) *BearerAuthorizer { + return &BearerAuthorizer{ realm: realm, service: service, key: key, diff --git a/pkg/api/bearer_oidc.go b/pkg/api/bearer_oidc.go new file mode 100644 index 00000000..4ccc2bee --- /dev/null +++ b/pkg/api/bearer_oidc.go @@ -0,0 +1,244 @@ +package api + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net/http" + "os" + "regexp" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + + zerr "zotregistry.dev/zot/v2/errors" + "zotregistry.dev/zot/v2/pkg/api/config" + "zotregistry.dev/zot/v2/pkg/cel" + "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). +const oidcProviderRefreshInterval = 1 * time.Minute + +var bearerOIDCTokenMatch = regexp.MustCompile("(?i)bearer (.*)") + +// OIDCBearerAuthorizer validates OIDC ID tokens for workload identity authentication. +type OIDCBearerAuthorizer struct { + providers []*oidcProvider +} + +// oidcProvider validates OIDC ID tokens for workload identity authentication. +// It holds the configuration for a single OIDC issuer. +type oidcProvider struct { + issuer string + audiences []string + claimProcessor *cel.ClaimProcessor + skipIssuerCheck bool + log log.Logger + + // The *oidc.IDTokenVerifier is created lazily to avoid network calls during initialization. + // We really don't want to block startup if the OIDC issuer is temporarily unreachable. + // Also, we periodically refresh the provider to pick up any changes in the issuer's configuration. + verifier *oidc.IDTokenVerifier + verifierMu sync.RWMutex + verifierDeadline time.Time +} + +// NewOIDCBearerAuthorizer creates a new OIDC bearer token authorizer. +func NewOIDCBearerAuthorizer(oidcConfig []config.BearerOIDCConfig, log log.Logger) (*OIDCBearerAuthorizer, error) { + providers := make([]*oidcProvider, 0, len(oidcConfig)) + issuers := make([]string, 0, len(oidcConfig)) + + for i := range oidcConfig { + conf := &oidcConfig[i] + provider, err := newOIDCProvider(conf, log) + if err != nil { + return nil, fmt.Errorf("%w: failed to create OIDC bearer provider[%d]: %w", zerr.ErrBadConfig, i, err) + } + + providers = append(providers, provider) + issuers = append(issuers, conf.Issuer) + } + + log.Info().Strs("issuers", issuers).Msg("the OIDC workload identity authentication was enabled") + + return &OIDCBearerAuthorizer{ + providers: providers, + }, nil +} + +// AuthenticateRequest is a convenience method that handles the full authentication flow +// and returns whether authentication succeeded and any error. +func (a *OIDCBearerAuthorizer) AuthenticateRequest(ctx context.Context, + authHeader string, +) (string, []string, bool, error) { + res, err := a.Authenticate(ctx, authHeader) + if err != nil { + return "", nil, false, err + } + + if res.Username == "" { + return "", nil, false, fmt.Errorf("%w: empty username", zerr.ErrInvalidBearerToken) + } + + return res.Username, res.Groups, true, nil +} + +// Authenticate validates an OIDC token and extracts the identity. +// Returns the username and groups extracted from the token claims. +func (a *OIDCBearerAuthorizer) Authenticate(ctx context.Context, header string) (*cel.ClaimResult, error) { + errs := make([]error, 0, len(a.providers)) + + for _, provider := range a.providers { + res, err := provider.authenticate(ctx, header) + if err == nil { + return res, nil + } + errs = append(errs, err) + } + switch len(errs) { + case 0: + return nil, zerr.ErrInvalidBearerToken + case 1: + return nil, errs[0] + default: + return nil, errors.Join(errs...) + } +} + +// newOIDCProvider creates a new OIDC provider based on the given configuration. +func newOIDCProvider(oidcConfig *config.BearerOIDCConfig, log log.Logger) (*oidcProvider, error) { + // Validate configuration + if oidcConfig.Issuer == "" { + return nil, fmt.Errorf("%w: issuer is required", zerr.ErrBadConfig) + } + claimProcessor, err := cel.NewClaimProcessor(oidcConfig.Audiences, oidcConfig.ClaimMapping) + if err != nil { + return nil, fmt.Errorf("failed to create claim processor: %w", err) + } + + return &oidcProvider{ + issuer: oidcConfig.Issuer, + audiences: oidcConfig.Audiences, + claimProcessor: claimProcessor, + skipIssuerCheck: oidcConfig.SkipIssuerVerification, + log: log, + }, nil +} + +func (a *oidcProvider) authenticate(ctx context.Context, header string) (*cel.ClaimResult, error) { + if header == "" { + return nil, zerr.ErrNoBearerToken + } + + // Extract token from Authorization header + tokenString := bearerOIDCTokenMatch.ReplaceAllString(header, "$1") + if tokenString == "" || tokenString == header { + return nil, zerr.ErrInvalidBearerToken + } + + // Get verifier. + verifier, err := a.getVerifier(ctx) + if err != nil { + a.log.Err(err).Msg("failed to get OIDC token verifier") + + return nil, fmt.Errorf("%w: %w", zerr.ErrInvalidOrUnreachableOIDCIssuer, err) + } + + // Verify the token + idToken, err := verifier.Verify(ctx, tokenString) + if err != nil { + a.log.Debug().Err(err).Msg("the OIDC token verification failed") + + return nil, fmt.Errorf("%w: %w", zerr.ErrInvalidBearerToken, err) + } + + // Extract claims + var claims map[string]any + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("%w: failed to extract claims: %w", zerr.ErrInvalidBearerToken, err) + } + + // Process claims to extract username and groups. + res, err := a.claimProcessor.Process(ctx, claims) + if err != nil { + a.log.Debug().Err(err).Msg("the OIDC token claim processing failed") + + return nil, fmt.Errorf("%w: failed to process claims: %w", zerr.ErrInvalidBearerToken, err) + } + + a.log.Debug().Str("username", res.Username).Strs("groups", res.Groups).Msg("the OIDC token was authenticated") + + return res, nil +} + +// getVerifier retrieves or refreshes the oidc.IDTokenVerifier as needed. +func (o *oidcProvider) getVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) { + // If the verifier is still fresh, return it. + o.verifierMu.RLock() + verifier, deadline := o.verifier, o.verifierDeadline + o.verifierMu.RUnlock() + if verifier != nil && time.Now().Before(deadline) { + return verifier, nil + } + + // Time to refresh the verifier. + if hc := GetBearerOIDCTestHTTPClient(); hc != nil { + ctx = oidc.ClientContext(ctx, hc) + } + p, err := oidc.NewProvider(ctx, o.issuer) + if err != nil { + return nil, fmt.Errorf("failed to refresh OIDC provider from issuer %s: %w", o.issuer, err) + } + verifier = p.Verifier(&oidc.Config{ + ClientID: "", // We'll check audiences manually + SkipIssuerCheck: o.skipIssuerCheck, + SkipClientIDCheck: true, // Check audiences manually to support multiple + SkipExpiryCheck: false, + Now: time.Now, + }) + + // Update the verifier and deadline. + o.verifierMu.Lock() + o.verifier = verifier + o.verifierDeadline = time.Now().Add(oidcProviderRefreshInterval) + o.verifierMu.Unlock() + + return o.verifier, nil +} + +// GetBearerOIDCTestHTTPClient returns an HTTP client for testing purposes. +// It looks up a test environment variable pointing to a PEM-encoded +// CA certificate to trust when making requests to the OIDC issuer. +// If no such variable is set, it returns nil. This environment variable +// is not meant for production use. +func GetBearerOIDCTestHTTPClient() *http.Client { + caFile := os.Getenv("ZOT_BEARER_OIDC_TEST_CA_FILE") + if caFile == "" { + return nil + } + caCert, err := os.ReadFile(caFile) + if err != nil { + return nil + } + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(caCert) { + return nil + } + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil + } + testTransport := defaultTransport.Clone() + testTransport.TLSClientConfig = &tls.Config{ + RootCAs: certPool, + MinVersion: tls.VersionTLS12, + } + + return &http.Client{Transport: testTransport} +} diff --git a/pkg/api/bearer_oidc_test.go b/pkg/api/bearer_oidc_test.go new file mode 100644 index 00000000..e0c34c52 --- /dev/null +++ b/pkg/api/bearer_oidc_test.go @@ -0,0 +1,834 @@ +package api_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "errors" + "maps" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/golang-jwt/jwt/v5" + . "github.com/smartystreets/goconvey/convey" + + "zotregistry.dev/zot/v2/pkg/api" + "zotregistry.dev/zot/v2/pkg/api/config" + "zotregistry.dev/zot/v2/pkg/log" +) + +// mockOIDCServer creates a mock OIDC provider server for testing. +func mockOIDCServer(t *testing.T, pubKey *rsa.PublicKey) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + + // OpenID configuration endpoint + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + config := map[string]any{ + "issuer": "http://" + r.Host, + "jwks_uri": "http://" + r.Host + "/jwks", + } + + _ = json.NewEncoder(w).Encode(config) + }) + + // JWKS endpoint + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Create JWK from public key + jwk := jose.JSONWebKey{ + Key: pubKey, + KeyID: "test-key-id", + Algorithm: string(jose.RS256), + Use: "sig", + } + + jwks := map[string]any{ + "keys": []jose.JSONWebKey{jwk}, + } + + _ = json.NewEncoder(w).Encode(jwks) + }) + + return httptest.NewServer(mux) +} + +// createTestOIDCToken creates a test OIDC ID token. +func createTestOIDCToken(privKey *rsa.PrivateKey, issuer, audience, subject string, + claims map[string]any, +) (string, error) { + now := time.Now() + + tokenClaims := jwt.MapClaims{ + "iss": issuer, + "aud": []string{audience}, // Must be a slice for CEL processing + "sub": subject, + "exp": now.Add(time.Hour).Unix(), + "iat": now.Unix(), + } + + // Add additional claims + maps.Copy(tokenClaims, claims) + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, tokenClaims) + token.Header["kid"] = "test-key-id" + + return token.SignedString(privKey) +} + +func TestOIDCBearerAuthorizer(t *testing.T) { + Convey("Test OIDC bearer token authorization", t, func() { + // Generate test keys + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + So(err, ShouldBeNil) + + pubKey := &privKey.PublicKey + + // Start mock OIDC server + server := mockOIDCServer(t, pubKey) + defer server.Close() + + issuer := server.URL + audience := "test-zot" + + logger := log.NewLogger("debug", "") + + Convey("Configuration validation", func() { + Convey("Empty config slice creates authorizer with no providers", func() { + authorizer, err := api.NewOIDCBearerAuthorizer([]config.BearerOIDCConfig{}, logger) + So(err, ShouldBeNil) + So(authorizer, ShouldNotBeNil) + // But authentication will always fail + result, err := authorizer.Authenticate(context.Background(), "Bearer token") + So(err, ShouldNotBeNil) + So(result, ShouldBeNil) + }) + + Convey("Empty issuer should fail", func() { + cfg := []config.BearerOIDCConfig{{ + Audiences: []string{audience}, + }} + _, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldNotBeNil) + }) + + Convey("Empty audiences should fail", func() { + cfg := []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{}, + }} + _, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldNotBeNil) + }) + + Convey("Valid config should succeed", func() { + cfg := []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + }} + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + So(authorizer, ShouldNotBeNil) + }) + }) + + Convey("Token authentication", func() { + cfg := []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + }} + + ctx := context.Background() + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + + Convey("Empty header should fail", func() { + result, err := authorizer.Authenticate(ctx, "") + So(err, ShouldNotBeNil) + So(result, ShouldBeNil) + }) + + Convey("Invalid token format should fail", func() { + result, err := authorizer.Authenticate(ctx, "Bearer invalid-token") + So(err, ShouldNotBeNil) + So(result, ShouldBeNil) + }) + + Convey("Valid token with default claims", func() { + subject := "test-user" //nolint:goconst + token, err := createTestOIDCToken(privKey, issuer, audience, subject, nil) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(result.Username, ShouldEqual, issuer+"/"+subject) + So(result.Groups, ShouldBeEmpty) + }) + + Convey("Valid token with groups", func() { + subject := "test-user" + testGroups := []string{"group1", "group2"} + + cfg := []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + ClaimMapping: &config.CELClaimValidationAndMapping{ + Groups: "claims.groups", + }, + }} + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + + token, err := createTestOIDCToken(privKey, issuer, audience, subject, map[string]any{ + "groups": testGroups, + }) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(result.Username, ShouldEqual, issuer+"/"+subject) + So(result.Groups, ShouldResemble, testGroups) + }) + + Convey("Token with wrong audience should fail", func() { + subject := "test-user" + token, err := createTestOIDCToken(privKey, issuer, "wrong-audience", subject, nil) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldNotBeNil) + So(result, ShouldBeNil) + }) + + Convey("Expired token should fail", func() { + now := time.Now() + subject := "test-user" + + tokenClaims := jwt.MapClaims{ + "iss": issuer, + "aud": []string{audience}, + "sub": subject, + "exp": now.Add(-time.Hour).Unix(), // Expired + "iat": now.Add(-2 * time.Hour).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, tokenClaims) + token.Header["kid"] = "test-key-id" + tokenString, err := token.SignedString(privKey) + So(err, ShouldBeNil) + + authHeader := "Bearer " + tokenString + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldNotBeNil) + So(result, ShouldBeNil) + }) + }) + + Convey("Custom claim mapping", func() { + customClaimName := "preferred_username" + customUsername := "custom-user" + + cfg := []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + ClaimMapping: &config.CELClaimValidationAndMapping{ + Username: "claims.preferred_username", + }, + }} + + ctx := context.Background() + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + + Convey("Extract username from custom claim", func() { + subject := "original-sub" + + token, err := createTestOIDCToken(privKey, issuer, audience, subject, map[string]any{ + customClaimName: customUsername, + }) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(result.Username, ShouldEqual, customUsername) + So(result.Groups, ShouldBeEmpty) + }) + + Convey("Error when custom claim missing (no fallback)", func() { + subject := "fallback-user" + token, err := createTestOIDCToken(privKey, issuer, audience, subject, nil) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + // With CEL expressions, missing claims cause an error (no automatic fallback) + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldNotBeNil) + So(result, ShouldBeNil) + }) + }) + + Convey("Multiple audiences", func() { + audiences := []string{"audience1", "audience2", "audience3"} + + cfg := []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: audiences, + }} + + ctx := context.Background() + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + + Convey("Token with first audience should work", func() { + subject := "test-user" + token, err := createTestOIDCToken(privKey, issuer, audiences[0], subject, nil) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(result.Username, ShouldEqual, issuer+"/"+subject) + So(result.Groups, ShouldBeEmpty) + }) + + Convey("Token with second audience should work", func() { + subject := "test-user" + token, err := createTestOIDCToken(privKey, issuer, audiences[1], subject, nil) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(result.Username, ShouldEqual, issuer+"/"+subject) + So(result.Groups, ShouldBeEmpty) + }) + }) + + Convey("Multiple OIDC providers", func() { + ctx := context.Background() + + // Create a second mock server with a different key + privKey2, err := rsa.GenerateKey(rand.Reader, 2048) + So(err, ShouldBeNil) + pubKey2 := &privKey2.PublicKey + server2 := mockOIDCServer(t, pubKey2) + defer server2.Close() + + issuer2 := server2.URL + audience2 := "test-zot-2" + + Convey("Token valid for second provider succeeds", func() { + // Configure two providers - token is only valid for the second one + cfg := []config.BearerOIDCConfig{ + { + Issuer: issuer, + Audiences: []string{audience}, + }, + { + Issuer: issuer2, + Audiences: []string{audience2}, + }, + } + + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + + // Create token for second provider + subject := "test-user" + token, err := createTestOIDCToken(privKey2, issuer2, audience2, subject, nil) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(result.Username, ShouldEqual, issuer2+"/"+subject) + }) + + Convey("Token valid for first provider succeeds immediately", func() { + cfg := []config.BearerOIDCConfig{ + { + Issuer: issuer, + Audiences: []string{audience}, + }, + { + Issuer: issuer2, + Audiences: []string{audience2}, + }, + } + + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + + // Create token for first provider + subject := "test-user" + token, err := createTestOIDCToken(privKey, issuer, audience, subject, nil) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(result.Username, ShouldEqual, issuer+"/"+subject) + }) + + Convey("Token invalid for all providers returns aggregated errors", func() { + cfg := []config.BearerOIDCConfig{ + { + Issuer: issuer, + Audiences: []string{audience}, + }, + { + Issuer: issuer2, + Audiences: []string{audience2}, + }, + } + + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + + // Create token with wrong audience for both providers + subject := "test-user" + token, err := createTestOIDCToken(privKey, issuer, "wrong-audience", subject, nil) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldNotBeNil) + So(result, ShouldBeNil) + // Error should contain information from both providers + So(err.Error(), ShouldContainSubstring, "invalid bearer token") + }) + }) + + Convey("AuthenticateRequest convenience method", func() { + cfg := []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + }} + + ctx := context.Background() + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + + Convey("Successful authentication returns username, groups, and true", func() { + subject := "test-user" + token, err := createTestOIDCToken(privKey, issuer, audience, subject, nil) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + username, groups, ok, err := authorizer.AuthenticateRequest(ctx, authHeader) + So(err, ShouldBeNil) + So(ok, ShouldBeTrue) + So(username, ShouldEqual, issuer+"/"+subject) + So(groups, ShouldBeEmpty) + }) + + Convey("Failed authentication returns false and error", func() { + result, groups, ok, err := authorizer.AuthenticateRequest(ctx, "Bearer invalid-token") + So(err, ShouldNotBeNil) + So(ok, ShouldBeFalse) + So(result, ShouldBeEmpty) + So(groups, ShouldBeEmpty) + }) + }) + + Convey("CEL validations", func() { + ctx := context.Background() + + Convey("Validation passes when expression is true", func() { + cfg := []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + ClaimMapping: &config.CELClaimValidationAndMapping{ + Validations: []config.CELValidation{ + { + Expression: "claims.email_verified == true", + Message: "email must be verified", + }, + }, + }, + }} + + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + + subject := "test-user" + token, err := createTestOIDCToken(privKey, issuer, audience, subject, map[string]any{ + "email_verified": true, + }) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(result.Username, ShouldEqual, issuer+"/"+subject) + }) + + Convey("Validation fails when expression is false", func() { + cfg := []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + ClaimMapping: &config.CELClaimValidationAndMapping{ + Validations: []config.CELValidation{ + { + Expression: "claims.email_verified == true", + Message: "email must be verified", + }, + }, + }, + }} + + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + + subject := "test-user" + token, err := createTestOIDCToken(privKey, issuer, audience, subject, map[string]any{ + "email_verified": false, + }) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldNotBeNil) + So(result, ShouldBeNil) + So(err.Error(), ShouldContainSubstring, "email must be verified") + }) + + Convey("CEL variables can be used in validations and username", func() { + cfg := []config.BearerOIDCConfig{{ + Issuer: issuer, + Audiences: []string{audience}, + ClaimMapping: &config.CELClaimValidationAndMapping{ + Variables: []config.CELVariable{ + { + Name: "org", + Expression: "claims.organization", + }, + }, + Validations: []config.CELValidation{ + { + Expression: "vars.org in ['allowed-org', 'another-org']", + Message: "organization not allowed", + }, + }, + Username: "vars.org + '/' + claims.sub", + }, + }} + + authorizer, err := api.NewOIDCBearerAuthorizer(cfg, logger) + So(err, ShouldBeNil) + + subject := "test-user" + token, err := createTestOIDCToken(privKey, issuer, audience, subject, map[string]any{ + "organization": "allowed-org", + }) + So(err, ShouldBeNil) + + authHeader := "Bearer " + token + + result, err := authorizer.Authenticate(ctx, authHeader) + So(err, ShouldBeNil) + So(result, ShouldNotBeNil) + So(result.Username, ShouldEqual, "allowed-org/test-user") + }) + }) + }) +} + +func TestBearerOIDCConfig(t *testing.T) { + Convey("Test Bearer OIDC configuration", t, func() { + Convey("IsBearerAuthEnabled with OIDC config", func() { + authConfig := &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + Audiences: []string{"zot"}, + }}, + }, + } + + So(authConfig.IsBearerAuthEnabled(), ShouldBeTrue) + So(authConfig.IsOIDCBearerAuthEnabled(), ShouldBeTrue) + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeFalse) + }) + + Convey("IsBearerAuthEnabled with traditional bearer", func() { + authConfig := &config.AuthConfig{ + Bearer: &config.BearerConfig{ + Realm: "zot", + Service: "zot-service", + Cert: "/path/to/cert", + }, + } + + So(authConfig.IsBearerAuthEnabled(), ShouldBeTrue) + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeTrue) + So(authConfig.IsOIDCBearerAuthEnabled(), ShouldBeFalse) + }) + + Convey("IsBearerAuthEnabled with both", func() { + authConfig := &config.AuthConfig{ + Bearer: &config.BearerConfig{ + Realm: "zot", + Service: "zot-service", + Cert: "/path/to/cert", + OIDC: []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + Audiences: []string{"zot"}, + }}, + }, + } + + So(authConfig.IsBearerAuthEnabled(), ShouldBeTrue) + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeTrue) + So(authConfig.IsOIDCBearerAuthEnabled(), ShouldBeTrue) + }) + + Convey("IsBearerAuthEnabled without proper config", func() { + authConfig := &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + // Missing audiences + }}, + }, + } + + So(authConfig.IsBearerAuthEnabled(), ShouldBeFalse) + So(authConfig.IsOIDCBearerAuthEnabled(), ShouldBeFalse) + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeFalse) + }) + + Convey("IsBearerAuthEnabled with nil bearer", func() { + authConfig := &config.AuthConfig{ + Bearer: nil, + } + + So(authConfig.IsBearerAuthEnabled(), ShouldBeFalse) + So(authConfig.IsOIDCBearerAuthEnabled(), ShouldBeFalse) + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeFalse) + }) + + Convey("IsOIDCBearerAuthEnabled with nil AuthConfig", func() { + var authConfig *config.AuthConfig = nil + + So(authConfig.IsOIDCBearerAuthEnabled(), ShouldBeFalse) + }) + + Convey("IsTraditionalBearerAuthEnabled with nil AuthConfig", func() { + var authConfig *config.AuthConfig = nil + + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeFalse) + }) + + Convey("IsTraditionalBearerAuthEnabled with partial config", func() { + // Missing Cert + authConfig := &config.AuthConfig{ + Bearer: &config.BearerConfig{ + Realm: "zot", + Service: "zot-service", + }, + } + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeFalse) + + // Missing Realm + authConfig = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + Service: "zot-service", + Cert: "/path/to/cert", + }, + } + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeFalse) + + // Missing Service + authConfig = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + Realm: "zot", + Cert: "/path/to/cert", + }, + } + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeFalse) + }) + }) +} + +func TestGetBearerOIDCTestHTTPClient(t *testing.T) { + Convey("Test GetBearerOIDCTestHTTPClient", t, func() { + // Save original env var and restore after test + originalEnv := os.Getenv("ZOT_BEARER_OIDC_TEST_CA_FILE") + Reset(func() { + os.Setenv("ZOT_BEARER_OIDC_TEST_CA_FILE", originalEnv) + }) + + Convey("Returns nil when env var is empty", func() { + os.Unsetenv("ZOT_BEARER_OIDC_TEST_CA_FILE") + + client := api.GetBearerOIDCTestHTTPClient() + So(client, ShouldBeNil) + }) + + Convey("Returns nil when CA file does not exist", func() { + os.Setenv("ZOT_BEARER_OIDC_TEST_CA_FILE", "/nonexistent/path/to/ca.crt") + + client := api.GetBearerOIDCTestHTTPClient() + So(client, ShouldBeNil) + }) + + Convey("Returns nil when CA file contains invalid PEM data", func() { + tmpDir := t.TempDir() + caFile := filepath.Join(tmpDir, "invalid-ca.crt") + + err := os.WriteFile(caFile, []byte("not a valid PEM certificate"), 0o600) + So(err, ShouldBeNil) + + os.Setenv("ZOT_BEARER_OIDC_TEST_CA_FILE", caFile) + + client := api.GetBearerOIDCTestHTTPClient() + So(client, ShouldBeNil) + }) + + Convey("Returns nil when CA file contains valid PEM but not a certificate", func() { + tmpDir := t.TempDir() + caFile := filepath.Join(tmpDir, "not-a-cert.pem") + + // Create a valid PEM block but with wrong type + pemBlock := &pem.Block{ + Type: "PRIVATE KEY", + Bytes: []byte("fake key data"), + } + pemData := pem.EncodeToMemory(pemBlock) + + err := os.WriteFile(caFile, pemData, 0o600) + So(err, ShouldBeNil) + + os.Setenv("ZOT_BEARER_OIDC_TEST_CA_FILE", caFile) + + client := api.GetBearerOIDCTestHTTPClient() + So(client, ShouldBeNil) + }) + + Convey("Returns configured HTTP client with valid CA file", func() { + tmpDir := t.TempDir() + caFile := filepath.Join(tmpDir, "ca.crt") + + // Generate a self-signed CA certificate + caCert := createTestCACertificate(t) + + err := os.WriteFile(caFile, caCert, 0o600) + So(err, ShouldBeNil) + + os.Setenv("ZOT_BEARER_OIDC_TEST_CA_FILE", caFile) + + client := api.GetBearerOIDCTestHTTPClient() + So(client, ShouldNotBeNil) + So(client.Transport, ShouldNotBeNil) + }) + + Convey("Returns nil when http.DefaultTransport is not *http.Transport", func() { + tmpDir := t.TempDir() + caFile := filepath.Join(tmpDir, "ca.crt") + + // Generate a valid CA certificate + caCert := createTestCACertificate(t) + + err := os.WriteFile(caFile, caCert, 0o600) + So(err, ShouldBeNil) + + os.Setenv("ZOT_BEARER_OIDC_TEST_CA_FILE", caFile) + + // Save the original DefaultTransport and restore after test + originalTransport := http.DefaultTransport + defer func() { + http.DefaultTransport = originalTransport + }() + + // Replace with a custom RoundTripper that is not *http.Transport + http.DefaultTransport = &customRoundTripper{} + + client := api.GetBearerOIDCTestHTTPClient() + So(client, ShouldBeNil) + }) + }) +} + +// customRoundTripper is a mock RoundTripper that is not *http.Transport. +type customRoundTripper struct{} + +var errNotImplemented = errors.New("not implemented") + +func (c *customRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errNotImplemented +} + +// createTestCACertificate generates a self-signed CA certificate for testing. +func createTestCACertificate(t *testing.T) []byte { + t.Helper() + + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("failed to generate private key: %v", err) + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Test CA"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) + if err != nil { + t.Fatalf("failed to create certificate: %v", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + }) + + return certPEM +} diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index c8e78592..392cdf10 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -129,9 +129,19 @@ func (a *AuthConfig) IsHtpasswdAuthEnabled() bool { // IsBearerAuthEnabled checks if Bearer authentication is enabled in this auth config. func (a *AuthConfig) IsBearerAuthEnabled() bool { + return a.IsTraditionalBearerAuthEnabled() || a.IsOIDCBearerAuthEnabled() +} + +// IsTraditionalBearerAuthEnabled checks if traditional Bearer authentication is enabled in this auth config. +func (a *AuthConfig) IsTraditionalBearerAuthEnabled() bool { return a != nil && a.Bearer != nil && a.Bearer.Cert != "" && 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() +} + // IsOpenIDAuthEnabled checks if OpenID authentication is enabled in this auth config. func (a *AuthConfig) IsOpenIDAuthEnabled() bool { if a == nil || a.OpenID == nil { @@ -183,6 +193,42 @@ type BearerConfig struct { Realm string Service string Cert string + // OIDC configuration for workload identity authentication + OIDC BearerOIDCConfigs `json:"oidc,omitempty" mapstructure:"oidc,omitempty"` +} + +// BearerOIDCConfigs is a slice of BearerOIDCConfig. +type BearerOIDCConfigs []BearerOIDCConfig + +func (b BearerOIDCConfigs) IsEnabled() bool { + for i := range b { + if b[i].Issuer != "" && len(b[i].Audiences) > 0 { + return true + } + } + + return false +} + +// BearerOIDCConfig configures OIDC token validation for workload identity. +// This enables workloads to authenticate using OIDC ID tokens in the Authorization header. +type BearerOIDCConfig struct { + // Issuer is the OIDC issuer URL. Required for OIDC workload identity. + // Example: "https://kubernetes.default.svc.cluster.local" + Issuer string `json:"issuer" mapstructure:"issuer"` + + // Audiences is a list of acceptable audiences for the OIDC token. + // At least one audience must be specified. + // Example: ["zot", "https://zot.example.com"] + Audiences []string `json:"audiences" mapstructure:"audiences"` + + // ClaimMapping specifies how OIDC claims are validated and mapped to Zot identities. + // Default: {"username":"claims.iss + '/' + claims.sub"} + ClaimMapping *CELClaimValidationAndMapping `json:"claimMapping,omitempty" mapstructure:"claimMapping,omitempty"` + + // SkipIssuerVerification skips issuer verification (for testing only). + // Default: false + SkipIssuerVerification bool `json:"skipIssuerVerification,omitempty" mapstructure:"skipIssuerVerification,omitempty"` } type SessionKeys struct { @@ -221,6 +267,46 @@ type ClaimMapping struct { Username string `mapstructure:"username,omitempty"` } +// CELClaimValidationAndMapping specifies Common Expression Language (CEL) expressions +// for validating and mapping OIDC claims. +type CELClaimValidationAndMapping struct { + // Variables is a list of CELVariable definitions used to extract variables from OIDC claims. + Variables []CELVariable `mapstructure:"variables,omitempty"` + + // Validations is a list of CELValidation definitions used to validate OIDC claims. + Validations []CELValidation `mapstructure:"validations,omitempty"` + + // Username is the CEL expression used to extract the username from OIDC claims. + // This expression should evaluate to a string value. + // Default: "claims.iss + '/' + claims.sub" + Username string `mapstructure:"username,omitempty"` + + // Groups is the CEL expression used to extract groups from OIDC claims. + // This expression should evaluate to a list of strings. + // Default: "" (no groups extracted) + Groups string `mapstructure:"groups,omitempty"` +} + +// CELVariable represents a CEL expression to extract a variable from OIDC claims. +type CELVariable struct { + // Name is the name of the variable to be extracted. + Name string `mapstructure:"name"` + + // Expression is the CEL expression that will extract the variable from the OIDC claims. + Expression string `mapstructure:"expression"` +} + +// CELValidation represents a CEL expression used for validating OIDC claims. +type CELValidation struct { + // Expression is the CEL expression used for validation. It should evaluate to a boolean value. + // If the expression evaluates to false, the validation fails and the associated error message + // is returned. + Expression string `mapstructure:"expression"` + + // Message is the error message returned if the expression evaluates to false. + Message string `mapstructure:"message"` +} + type MethodRatelimitConfig struct { Method string Rate int diff --git a/pkg/api/config/config_test.go b/pkg/api/config/config_test.go index 2c245410..0634013d 100644 --- a/pkg/api/config/config_test.go +++ b/pkg/api/config/config_test.go @@ -707,6 +707,66 @@ func TestConfig(t *testing.T) { So(authConfig.IsBearerAuthEnabled(), ShouldBeTrue) }) + Convey("Test IsTraditionalBearerAuthEnabled()", func() { + // Test with nil AuthConfig + var authConfig *config.AuthConfig = nil + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeFalse) + + // Test with AuthConfig but nil Bearer + authConfig = &config.AuthConfig{} + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeFalse) + + // Test with AuthConfig and Bearer configured with all required fields + authConfig = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + Cert: "/path/to/cert.pem", + Realm: "test-realm", + Service: "test-service", + }, + } + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeTrue) + + // Test with partial config (missing Cert) + authConfig = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + Realm: "test-realm", + Service: "test-service", + }, + } + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeFalse) + }) + + Convey("Test IsOIDCBearerAuthEnabled()", func() { + // Test with nil AuthConfig + var authConfig *config.AuthConfig = nil + So(authConfig.IsOIDCBearerAuthEnabled(), ShouldBeFalse) + + // Test with AuthConfig but nil Bearer + authConfig = &config.AuthConfig{} + So(authConfig.IsOIDCBearerAuthEnabled(), ShouldBeFalse) + + // Test with AuthConfig and OIDC Bearer configured + authConfig = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + Audiences: []string{"zot"}, + }}, + }, + } + So(authConfig.IsOIDCBearerAuthEnabled(), ShouldBeTrue) + + // Test with invalid OIDC config (missing audiences) + authConfig = &config.AuthConfig{ + Bearer: &config.BearerConfig{ + OIDC: []config.BearerOIDCConfig{{ + Issuer: "https://issuer.example.com", + }}, + }, + } + So(authConfig.IsOIDCBearerAuthEnabled(), ShouldBeFalse) + }) + Convey("Test IsOpenIDAuthEnabled()", func() { // Test with nil AuthConfig var authConfig *config.AuthConfig = nil @@ -2152,6 +2212,8 @@ func TestConfig(t *testing.T) { So(authConfig.IsLdapAuthEnabled(), ShouldBeFalse) So(authConfig.IsHtpasswdAuthEnabled(), ShouldBeFalse) So(authConfig.IsBearerAuthEnabled(), ShouldBeFalse) + So(authConfig.IsTraditionalBearerAuthEnabled(), ShouldBeFalse) + So(authConfig.IsOIDCBearerAuthEnabled(), ShouldBeFalse) So(authConfig.IsOpenIDAuthEnabled(), ShouldBeFalse) So(authConfig.IsAPIKeyEnabled(), ShouldBeFalse) So(authConfig.IsBasicAuthnEnabled(), ShouldBeFalse) diff --git a/pkg/api/controller.go b/pkg/api/controller.go index 3efff12a..fadb839e 100644 --- a/pkg/api/controller.go +++ b/pkg/api/controller.go @@ -282,7 +282,8 @@ func (c *Controller) Init() error { // log authentication methods status authConfig := c.Config.CopyAuthConfig() - c.Log.Info().Bool("enabled", authConfig.IsBearerAuthEnabled()).Msg("bearer authentication") + c.Log.Info().Bool("enabled", authConfig.IsTraditionalBearerAuthEnabled()).Msg("jwt bearer authentication") + c.Log.Info().Bool("enabled", authConfig.IsOIDCBearerAuthEnabled()).Msg("oidc bearer authentication") c.Log.Info().Bool("enabled", authConfig.IsHtpasswdAuthEnabled()).Msg("basic authentication (htpasswd)") c.Log.Info().Bool("enabled", authConfig.IsLdapAuthEnabled()).Msg("basic authentication (LDAP)") c.Log.Info().Bool("enabled", authConfig.IsAPIKeyEnabled()).Msg("basic authentication (API key)") @@ -441,7 +442,8 @@ func (c *Controller) LoadNewConfig(newConfig *config.Config) { Msg("loaded new configuration settings") // log authentication methods status - c.Log.Info().Bool("enabled", authConfig.IsBearerAuthEnabled()).Msg("bearer authentication") + c.Log.Info().Bool("enabled", authConfig.IsTraditionalBearerAuthEnabled()).Msg("jwt bearer authentication") + c.Log.Info().Bool("enabled", authConfig.IsOIDCBearerAuthEnabled()).Msg("oidc bearer authentication") c.Log.Info().Bool("enabled", authConfig.IsHtpasswdAuthEnabled()).Msg("basic authentication (htpasswd)") c.Log.Info().Bool("enabled", authConfig.IsLdapAuthEnabled()).Msg("basic authentication (LDAP)") c.Log.Info().Bool("enabled", authConfig.IsAPIKeyEnabled()).Msg("basic authentication (API key)") diff --git a/pkg/cel/claim_processor.go b/pkg/cel/claim_processor.go new file mode 100644 index 00000000..1c13741d --- /dev/null +++ b/pkg/cel/claim_processor.go @@ -0,0 +1,257 @@ +package cel + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/google/cel-go/common/types" + + zerr "zotregistry.dev/zot/v2/errors" + "zotregistry.dev/zot/v2/pkg/api/config" +) + +// defaultUsernameExpr is the default CEL expression for extracting the username from OIDC claims. +const defaultUsernameExpr = "claims.iss + '/' + claims.sub" + +// ClaimResult holds the result of processing OIDC claims. +type ClaimResult struct { + Username string + Groups []string +} + +// ClaimProcessor processes OIDC claims using CEL expressions. +// It validates and maps claims to Zot identities. +type ClaimProcessor struct { + variables []variable + validations []validation + audiences []string + username *Expression + groups *Expression +} + +// variable contains a compiled CEL expression for extracting +// a variable from OIDC claims. +type variable struct { + name string + expr *Expression +} + +// validation contains a compiled CEL expression for validating +// OIDC claims. +type validation struct { + expr *Expression + msg string +} + +// NewClaimProcessor creates a new ClaimProcessor. +func NewClaimProcessor(audiences []string, conf *config.CELClaimValidationAndMapping) (*ClaimProcessor, error) { + // Sanitize and validate audiences. + audiences = slices.Clone(audiences) + if len(audiences) == 0 { + return nil, zerr.ErrOIDCNoAudiences + } + + for i := range audiences { + audiences[i] = strings.TrimSpace(audiences[i]) + if audiences[i] == "" { + return nil, fmt.Errorf("audience[%d]: %w", i, zerr.ErrOIDCEmptyAudience) + } + } + + // Apply defaults. + if conf == nil { + conf = &config.CELClaimValidationAndMapping{ + Username: defaultUsernameExpr, + } + } + if conf.Username == "" { + conf.Username = defaultUsernameExpr + } + + // Parse variable expressions. + variables := make([]variable, 0, len(conf.Variables)) + + for i, varConf := range conf.Variables { + if varConf.Name == "" { + return nil, fmt.Errorf("variable[%d]: %w", i, zerr.ErrOIDCEmptyVariableName) + } + + expr, err := NewExpression(varConf.Expression, + WithCompile(), + WithStructVariables("claims", "vars")) + if err != nil { + return nil, fmt.Errorf("failed to parse CEL expression for variable[%d] (name: %s): %w", + i, varConf.Name, err) + } + + variables = append(variables, variable{ + name: varConf.Name, + expr: expr, + }) + } + + // Parse validation expressions. + validations := make([]validation, 0, len(conf.Validations)) + + for i, valConf := range conf.Validations { + if valConf.Message == "" { + return nil, fmt.Errorf("validation[%d]: %w", i, zerr.ErrOIDCEmptyValidationMsg) + } + + expr, err := NewExpression(valConf.Expression, + WithCompile(), + WithStructVariables("claims", "vars"), + WithOutputType(types.BoolType)) + if err != nil { + return nil, fmt.Errorf("failed to parse CEL expression for validation[%d]: %w", i, err) + } + + validations = append(validations, validation{ + expr: expr, + msg: valConf.Message, + }) + } + + // Parse username expression. + username, err := NewExpression(conf.Username, + WithCompile(), + WithStructVariables("claims", "vars")) + if err != nil { + return nil, fmt.Errorf("failed to parse CEL expression for username: %w", err) + } + + // Parse groups expression if provided. + var groups *Expression + if conf.Groups != "" { + groups, err = NewExpression(conf.Groups, + WithCompile(), + WithStructVariables("claims", "vars")) + if err != nil { + return nil, fmt.Errorf("failed to parse CEL expression for groups: %w", err) + } + } + + return &ClaimProcessor{ + variables: variables, + validations: validations, + audiences: audiences, + username: username, + groups: groups, + }, nil +} + +// Process processes the OIDC claims applying all validations, including CEL expressions +// and audiences, and returns the mapped username and groups. +func (c *ClaimProcessor) Process(ctx context.Context, claims map[string]any) (*ClaimResult, error) { + // First, validate the audience. + if err := c.validateAudience(claims); err != nil { + return nil, err + } + + // Next, we extract variables. The process is iterative: + // variable expressions can refer to both the claims and + // previously extracted variables. + vars := make(map[string]any) + data := map[string]any{ + "vars": vars, + "claims": claims, + } + + for i := range c.variables { + celVar := c.variables[i] + + val, err := celVar.expr.Evaluate(ctx, data) + if err != nil { + return nil, fmt.Errorf("failed to evaluate variable '%s': %w", celVar.name, err) + } + + vars[celVar.name] = val + } + + // Next, we run validations. If any validation fails, we + // return an error. Validations can refer to both claims + // and the extracted variables. + for i := range c.validations { + celVal := c.validations[i] + + val, err := celVal.expr.EvaluateBoolean(ctx, data) + if err != nil { + return nil, fmt.Errorf("failed to evaluate validation expression: %w", err) + } + + if !val { + return nil, fmt.Errorf("%w: %s", zerr.ErrOIDCValidationFailed, celVal.msg) + } + } + + // Next, we extract the username. It can refer to both + // claims and the extracted variables. + username, err := c.username.EvaluateString(ctx, data) + if err != nil { + return nil, fmt.Errorf("failed to evaluate username expression: %w", err) + } + + // Finally, we extract groups if a groups expression is provided. + // It can refer to both claims and the extracted variables. + var groups []string + if c.groups != nil { + groups, err = c.groups.EvaluateStringSlice(ctx, data) + if err != nil { + return nil, fmt.Errorf("failed to evaluate groups expression: %w", err) + } + } + + return &ClaimResult{ + Username: username, + Groups: groups, + }, nil +} + +// validateAudience checks if the provided audiences contain at least one of the expected audiences. +func (c *ClaimProcessor) validateAudience(claims map[string]any) error { + audiencesValue, ok := claims["aud"] + if !ok { + return fmt.Errorf("%w: missing 'aud' claim", zerr.ErrOIDCNoAudiences) + } + + audiences := make(map[string]struct{}) + + if audiencesAnySlice, ok := audiencesValue.([]any); ok { + for _, audValue := range audiencesAnySlice { + aud, ok := audValue.(string) + if !ok { + return fmt.Errorf("%w: 'aud' claim contains non-string value", zerr.ErrOIDCInvalidAudiences) + } + + audiences[aud] = struct{}{} + } + } + + if audiencesStringSlice, ok := audiencesValue.([]string); ok { + for _, aud := range audiencesStringSlice { + audiences[aud] = struct{}{} + } + } + + if audiencesString, ok := audiencesValue.(string); ok { + audiences[audiencesString] = struct{}{} + } + + hasAudience := false + + for _, aud := range c.audiences { + if _, ok := audiences[aud]; ok { + hasAudience = true + + break + } + } + + if !hasAudience { + return fmt.Errorf("%w: token=%v, expected=%v", zerr.ErrOIDCAudienceMismatch, audiences, c.audiences) + } + + return nil +} diff --git a/pkg/cel/claim_processor_test.go b/pkg/cel/claim_processor_test.go new file mode 100644 index 00000000..3ed9f0a2 --- /dev/null +++ b/pkg/cel/claim_processor_test.go @@ -0,0 +1,644 @@ +package cel_test + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" + + "zotregistry.dev/zot/v2/pkg/api/config" + "zotregistry.dev/zot/v2/pkg/cel" +) + +func TestNewClaimProcessor(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + audiences []string + conf *config.CELClaimValidationAndMapping + err string + }{ + { + name: "nil config uses defaults", + audiences: []string{"my-audience"}, + conf: nil, + }, + { + name: "empty config uses defaults", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{}, + }, + { + name: "custom username expression", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Username: "claims.email", + }, + }, + { + name: "custom groups expression", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Groups: "claims.groups", + }, + }, + { + name: "multiple audiences", + audiences: []string{"aud1", "aud2", "aud3"}, + conf: nil, + }, + { + name: "with variables", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Variables: []config.CELVariable{ + {Name: "org", Expression: "claims.org"}, + {Name: "team", Expression: "claims.team"}, + }, + }, + }, + { + name: "with validations", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Validations: []config.CELValidation{ + {Expression: "claims.email_verified == true", Message: "email must be verified"}, + }, + }, + }, + { + name: "empty audiences", + audiences: []string{}, + conf: nil, + err: "at least one audience must be specified", + }, + { + name: "nil audiences", + audiences: nil, + conf: nil, + err: "at least one audience must be specified", + }, + { + name: "empty audience in list", + audiences: []string{"valid", ""}, + conf: nil, + err: "audience[1]:", + }, + { + name: "variable with empty name", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Variables: []config.CELVariable{ + {Name: "", Expression: "claims.org"}, + }, + }, + err: "variable[0]:", + }, + { + name: "variable with invalid expression", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Variables: []config.CELVariable{ + {Name: "org", Expression: "claims."}, + }, + }, + err: "failed to parse CEL expression for variable[0] (name: org)", + }, + { + name: "validation with empty message", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Validations: []config.CELValidation{ + {Expression: "true", Message: ""}, + }, + }, + err: "validation[0]:", + }, + { + name: "validation with invalid expression", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Validations: []config.CELValidation{ + {Expression: "claims.", Message: "some error"}, + }, + }, + err: "failed to parse CEL expression for validation[0]", + }, + { + name: "invalid username expression", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Username: "claims.", + }, + err: "failed to parse CEL expression for username", + }, + { + name: "invalid groups expression", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Groups: "claims.", + }, + err: "failed to parse CEL expression for groups", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + gomega := NewWithT(t) + + processor, err := cel.NewClaimProcessor(testCase.audiences, testCase.conf) + + if testCase.err != "" { + gomega.Expect(err).To(HaveOccurred()) + gomega.Expect(err.Error()).To(ContainSubstring(testCase.err)) + gomega.Expect(processor).To(BeNil()) + } else { + gomega.Expect(err).NotTo(HaveOccurred()) + gomega.Expect(processor).NotTo(BeNil()) + } + }) + } +} + +func TestClaimProcessor_Process(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + audiences []string + conf *config.CELClaimValidationAndMapping + claims map[string]any + username string + groups []string + err string + }{ + { + name: "default config extracts iss/sub as username", + audiences: []string{"my-audience"}, + conf: nil, + claims: map[string]any{ + "iss": "https://issuer.example.com", + "sub": "user123", + "aud": []any{"my-audience"}, + }, + username: "https://issuer.example.com/user123", + groups: nil, + }, + { + name: "custom username from email claim", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Username: "claims.email", + }, + claims: map[string]any{ + "sub": "user123", + "email": "user@example.com", + "aud": []any{"my-audience"}, + }, + username: "user@example.com", + groups: nil, + }, + { + name: "extract groups from claims", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Groups: "claims.groups", + }, + claims: map[string]any{ + "iss": "https://issuer.example.com", + "sub": "user123", + "groups": []string{"admin", "developers"}, + "aud": []any{"my-audience"}, + }, + username: "https://issuer.example.com/user123", + groups: []string{"admin", "developers"}, + }, + { + name: "extract groups from any slice", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Groups: "claims.groups", + }, + claims: map[string]any{ + "iss": "https://issuer.example.com", + "sub": "user123", + "groups": []any{"admin", "developers"}, + "aud": []any{"my-audience"}, + }, + username: "https://issuer.example.com/user123", + groups: []string{"admin", "developers"}, + }, + { + name: "audience validation - single audience match", + audiences: []string{"my-audience"}, + conf: nil, + claims: map[string]any{ + "iss": "https://issuer.example.com", + "sub": "user123", + "aud": []any{"my-audience"}, + }, + username: "https://issuer.example.com/user123", + }, + { + name: "audience validation - multiple audiences, one matches", + audiences: []string{"aud1", "aud2"}, + conf: nil, + claims: map[string]any{ + "iss": "https://issuer.example.com", + "sub": "user123", + "aud": []any{"aud2", "other"}, + }, + username: "https://issuer.example.com/user123", + }, + { + name: "audience validation - token has multiple, config has one", + audiences: []string{"aud2"}, + conf: nil, + claims: map[string]any{ + "iss": "https://issuer.example.com", + "sub": "user123", + "aud": []any{"aud1", "aud2", "aud3"}, + }, + username: "https://issuer.example.com/user123", + }, + { + name: "audience validation - single string audience matches", + audiences: []string{"my-audience"}, + conf: nil, + claims: map[string]any{ + "iss": "https://issuer.example.com", + "sub": "user123", + "aud": "my-audience", + }, + username: "https://issuer.example.com/user123", + }, + { + name: "audience validation - []string type audience matches", + audiences: []string{"my-audience"}, + conf: nil, + claims: map[string]any{ + "iss": "https://issuer.example.com", + "sub": "user123", + "aud": []string{"my-audience", "other-audience"}, + }, + username: "https://issuer.example.com/user123", + }, + { + name: "audience validation fails - single string audience no match", + audiences: []string{"expected-aud"}, + conf: nil, + claims: map[string]any{ + "iss": "https://issuer.example.com", + "sub": "user123", + "aud": "other-aud", + }, + err: "does not match any of the expected audiences", + }, + { + name: "audience validation fails - no match", + audiences: []string{"expected-aud"}, + conf: nil, + claims: map[string]any{ + "sub": "user123", + "aud": []any{"other-aud"}, + }, + err: "token audience does not match any of the expected audiences", + }, + { + name: "audience validation fails - empty token audience", + audiences: []string{"expected-aud"}, + conf: nil, + claims: map[string]any{ + "sub": "user123", + "aud": []any{}, + }, + err: "does not match any of the expected audiences", + }, + { + name: "variables can be used in username expression", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Variables: []config.CELVariable{ + {Name: "prefix", Expression: "'user-'"}, + }, + Username: "vars.prefix + claims.sub", + }, + claims: map[string]any{ + "sub": "123", + "aud": []any{"my-audience"}, + }, + username: "user-123", + }, + { + name: "variables can reference claims", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Variables: []config.CELVariable{ + {Name: "domain", Expression: "claims.email.split('@')[1]"}, + }, + Username: "vars.domain + '/' + claims.sub", + }, + claims: map[string]any{ + "sub": "user123", + "email": "user@example.com", + "aud": []any{"my-audience"}, + }, + username: "example.com/user123", + }, + { + name: "variables can reference other variables", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Variables: []config.CELVariable{ + {Name: "org", Expression: "claims.org"}, + {Name: "fullOrg", Expression: "'org-' + vars.org"}, + }, + Username: "vars.fullOrg + '/' + claims.sub", + }, + claims: map[string]any{ + "sub": "user123", + "org": "myorg", + "aud": []any{"my-audience"}, + }, + username: "org-myorg/user123", + }, + { + name: "validation passes", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Validations: []config.CELValidation{ + {Expression: "claims.email_verified == true", Message: "email must be verified"}, + }, + }, + claims: map[string]any{ + "iss": "https://issuer.example.com", + "sub": "user123", + "email_verified": true, + "aud": []any{"my-audience"}, + }, + username: "https://issuer.example.com/user123", + }, + { + name: "validation fails", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Validations: []config.CELValidation{ + {Expression: "claims.email_verified == true", Message: "email must be verified"}, + }, + }, + claims: map[string]any{ + "sub": "user123", + "email_verified": false, + "aud": []any{"my-audience"}, + }, + err: "OIDC claim validation failed: email must be verified", + }, + { + name: "multiple validations all pass", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Validations: []config.CELValidation{ + {Expression: "claims.email_verified == true", Message: "email must be verified"}, + {Expression: "claims.org == 'myorg'", Message: "must be in myorg"}, + }, + }, + claims: map[string]any{ + "iss": "https://issuer.example.com", + "sub": "user123", + "email_verified": true, + "org": "myorg", + "aud": []any{"my-audience"}, + }, + username: "https://issuer.example.com/user123", + }, + { + name: "multiple validations - second fails", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Validations: []config.CELValidation{ + {Expression: "claims.email_verified == true", Message: "email must be verified"}, + {Expression: "claims.org == 'myorg'", Message: "must be in myorg"}, + }, + }, + claims: map[string]any{ + "sub": "user123", + "email_verified": true, + "org": "otherorg", + "aud": []any{"my-audience"}, + }, + err: "OIDC claim validation failed: must be in myorg", + }, + { + name: "validation can use variables", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Variables: []config.CELVariable{ + {Name: "allowedOrgs", Expression: "['org1', 'org2', 'org3']"}, + }, + Validations: []config.CELValidation{ + {Expression: "claims.org in vars.allowedOrgs", Message: "organization not allowed"}, + }, + }, + claims: map[string]any{ + "iss": "https://issuer.example.com", + "sub": "user123", + "org": "org2", + "aud": []any{"my-audience"}, + }, + username: "https://issuer.example.com/user123", + }, + { + name: "validation using variables fails", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Variables: []config.CELVariable{ + {Name: "allowedOrgs", Expression: "['org1', 'org2', 'org3']"}, + }, + Validations: []config.CELValidation{ + {Expression: "claims.org in vars.allowedOrgs", Message: "organization not allowed"}, + }, + }, + claims: map[string]any{ + "sub": "user123", + "org": "org4", + "aud": []any{"my-audience"}, + }, + err: "OIDC claim validation failed: organization not allowed", + }, + { + name: "username expression evaluation error", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Username: "claims.nonexistent", + }, + claims: map[string]any{ + "sub": "user123", + "aud": []any{"my-audience"}, + }, + err: "failed to evaluate username expression", + }, + { + name: "groups expression evaluation error", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Username: "claims.sub", + Groups: "claims.nonexistent", + }, + claims: map[string]any{ + "sub": "user123", + "aud": []any{"my-audience"}, + }, + err: "failed to evaluate groups expression", + }, + { + name: "variable expression evaluation error", + audiences: []string{"my-audience"}, + conf: &config.CELClaimValidationAndMapping{ + Variables: []config.CELVariable{ + {Name: "bad", Expression: "claims.nonexistent"}, + }, + }, + claims: map[string]any{ + "sub": "user123", + "aud": []any{"my-audience"}, + }, + err: "failed to evaluate variable 'bad'", + }, + { + name: "complex real-world scenario - GitHub Actions OIDC", + audiences: []string{"zot-registry"}, + conf: &config.CELClaimValidationAndMapping{ + Variables: []config.CELVariable{ + {Name: "repo", Expression: "claims.repository"}, + {Name: "owner", Expression: "claims.repository_owner"}, + }, + Validations: []config.CELValidation{ + {Expression: "vars.owner == 'myorg'", Message: "only myorg repositories allowed"}, + {Expression: "claims.ref.startsWith('refs/heads/')", Message: "must be a branch ref"}, + }, + Username: "vars.repo", + Groups: "['github-actions', 'ci']", + }, + claims: map[string]any{ + "sub": "repo:myorg/myrepo:ref:refs/heads/main", + "repository": "myorg/myrepo", + "repository_owner": "myorg", + "ref": "refs/heads/main", + "aud": []any{"zot-registry"}, + }, + username: "myorg/myrepo", + groups: []string{"github-actions", "ci"}, + }, + { + name: "complex real-world scenario - Kubernetes service account", + audiences: []string{"zot"}, + conf: &config.CELClaimValidationAndMapping{ + Variables: []config.CELVariable{ + {Name: "ns", Expression: "claims['kubernetes.io/serviceaccount/namespace']"}, + {Name: "sa", Expression: "claims['kubernetes.io/serviceaccount/service-account.name']"}, + }, + Validations: []config.CELValidation{ + {Expression: "vars.ns in ['production', 'staging']", Message: "namespace not allowed"}, + }, + Username: "vars.ns + ':' + vars.sa", + Groups: "['k8s-workloads']", + }, + claims: map[string]any{ + "sub": "system:serviceaccount:production:my-app", + "kubernetes.io/serviceaccount/namespace": "production", + "kubernetes.io/serviceaccount/service-account.name": "my-app", + "aud": []any{"zot"}, + }, + username: "production:my-app", + groups: []string{"k8s-workloads"}, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + gomega := NewWithT(t) + + processor, err := cel.NewClaimProcessor(testCase.audiences, testCase.conf) + gomega.Expect(err).NotTo(HaveOccurred()) + + result, err := processor.Process(context.Background(), testCase.claims) + + if testCase.err != "" { + gomega.Expect(err).To(HaveOccurred()) + gomega.Expect(err.Error()).To(ContainSubstring(testCase.err)) + gomega.Expect(result).To(BeNil()) + } else { + gomega.Expect(err).NotTo(HaveOccurred()) + gomega.Expect(result).NotTo(BeNil()) + gomega.Expect(result.Username).To(Equal(testCase.username)) + gomega.Expect(result.Groups).To(Equal(testCase.groups)) + } + }) + } +} + +func TestClaimProcessor_Process_AudienceEdgeCases(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + audiences []string + claims map[string]any + err string + }{ + { + name: "missing aud claim", + audiences: []string{"my-audience"}, + claims: map[string]any{ + "sub": "user123", + }, + err: "missing 'aud' claim", + }, + { + name: "aud claim with wrong type (integer)", + audiences: []string{"my-audience"}, + claims: map[string]any{ + "sub": "user123", + "iss": "test-issuer", + "aud": 12345, + }, + err: "does not match any of the expected audiences", + }, + { + name: "aud claim with wrong type (map)", + audiences: []string{"my-audience"}, + claims: map[string]any{ + "sub": "user123", + "iss": "test-issuer", + "aud": map[string]any{"key": "value"}, + }, + err: "does not match any of the expected audiences", + }, + { + name: "aud array contains non-string value", + audiences: []string{"my-audience"}, + claims: map[string]any{ + "sub": "user123", + "iss": "test-issuer", + "aud": []any{"valid-aud", 123}, + }, + err: "'aud' claim contains non-string value", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + gomega := NewWithT(t) + + processor, err := cel.NewClaimProcessor(testCase.audiences, nil) + gomega.Expect(err).NotTo(HaveOccurred()) + + result, err := processor.Process(context.Background(), testCase.claims) + + gomega.Expect(err).To(HaveOccurred()) + gomega.Expect(err.Error()).To(ContainSubstring(testCase.err)) + gomega.Expect(result).To(BeNil()) + }) + } +} diff --git a/pkg/cel/expression.go b/pkg/cel/expression.go new file mode 100644 index 00000000..05ecf6e2 --- /dev/null +++ b/pkg/cel/expression.go @@ -0,0 +1,187 @@ +/* +Copyright 2025 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Copied from: +// https://github.com/fluxcd/pkg/blob/d6af17e6f40bfdd628ab1f7793bc878d5d90e8b6/runtime/cel/expression.go + +//nolint:all // Code copied from external project +package cel + +import ( + "context" + "fmt" + "reflect" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/ext" +) + +// Expression represents a parsed CEL expression. +type Expression struct { + expr string + prog cel.Program +} + +// Option is a function that configures the CEL expression. +type Option func(*options) + +type options struct { + variables []cel.EnvOption + compile bool + outputType *cel.Type +} + +// WithStructVariables declares variables of type google.protobuf.Struct. +func WithStructVariables(vars ...string) Option { + return func(o *options) { + for _, v := range vars { + d := cel.Variable(v, cel.ObjectType("google.protobuf.Struct")) + o.variables = append(o.variables, d) + } + } +} + +// WithCompile specifies that the expression should be compiled, +// which provides stricter checks at parse time, before evaluation. +func WithCompile() Option { + return func(o *options) { + o.compile = true + } +} + +// WithOutputType specifies the expected output type of the expression. +func WithOutputType(t *cel.Type) Option { + return func(o *options) { + o.outputType = t + } +} + +// NewExpression parses the given CEL expression and returns a new Expression. +func NewExpression(expr string, opts ...Option) (*Expression, error) { + var o options + for _, opt := range opts { + opt(&o) + } + + if !o.compile && (o.outputType != nil || len(o.variables) > 0) { + return nil, fmt.Errorf("output type and variables can only be set when compiling the expression") + } + + envOpts := append([]cel.EnvOption{ + cel.HomogeneousAggregateLiterals(), + cel.EagerlyValidateDeclarations(true), + cel.DefaultUTCTimeZone(true), + cel.CrossTypeNumericComparisons(true), + cel.OptionalTypes(), + ext.Strings(), + ext.Sets(), + ext.Encoders(), + }, o.variables...) + + env, err := cel.NewEnv(envOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create CEL environment: %w", err) + } + + parse := env.Parse + if o.compile { + parse = env.Compile + } + e, issues := parse(expr) + if issues != nil { + return nil, fmt.Errorf("failed to parse the CEL expression '%s': %s", expr, issues.String()) + } + + if w, g := o.outputType, e.OutputType(); w != nil && w != g { + return nil, fmt.Errorf("CEL expression output type mismatch: expected %s, got %s", w, g) + } + + progOpts := []cel.ProgramOption{ + cel.EvalOptions(cel.OptOptimize), + + // 100 is the kubernetes default: + // https://github.com/kubernetes/kubernetes/blob/3f26d005571dc5903e7cebae33ada67986bc40f3/staging/src/k8s.io/apiserver/pkg/apis/cel/config.go#L33-L35 + cel.InterruptCheckFrequency(100), + } + + prog, err := env.Program(e, progOpts...) + if err != nil { + return nil, fmt.Errorf("failed to create CEL program: %w", err) + } + + return &Expression{ + expr: expr, + prog: prog, + }, nil +} + +// String returns the original CEL expression string. +func (e *Expression) String() string { + return e.expr +} + +// EvaluateBoolean evaluates the expression with the given data and returns the result as a boolean. +func (e *Expression) EvaluateBoolean(ctx context.Context, data map[string]any) (bool, error) { + val, _, err := e.prog.ContextEval(ctx, data) + if err != nil { + return false, fmt.Errorf("failed to evaluate the CEL expression '%s': %w", e.expr, err) + } + result, ok := val.(types.Bool) + if !ok { + return false, fmt.Errorf("failed to evaluate CEL expression as boolean: '%s'", e.expr) + } + return bool(result), nil +} + +// EvaluateString evaluates the expression with the given data and returns the result as a string. +func (e *Expression) EvaluateString(ctx context.Context, data map[string]any) (string, error) { + val, _, err := e.prog.ContextEval(ctx, data) + if err != nil { + return "", fmt.Errorf("failed to evaluate the CEL expression '%s': %w", e.expr, err) + } + result, ok := val.(types.String) + if !ok { + return "", fmt.Errorf("failed to evaluate CEL expression as string: '%s'", e.expr) + } + return string(result), nil +} + +// EvaluateStringSlice evaluates the expression with the given data and returns the result as []string. +func (e *Expression) EvaluateStringSlice(ctx context.Context, data map[string]any) ([]string, error) { + val, _, err := e.prog.ContextEval(ctx, data) + if err != nil { + return nil, fmt.Errorf("failed to evaluate the CEL expression '%s': %w", e.expr, err) + } + v, err := val.ConvertToNative(reflect.TypeOf([]string{})) + if err != nil { + return nil, fmt.Errorf("failed to evaluate CEL expression '%s' as []string: %w", e.expr, err) + } + result, ok := v.([]string) + if !ok { + return nil, fmt.Errorf("failed to type-assert CEL expression result as []string: '%s'", e.expr) + } + return result, nil +} + +// Evaluate evaluates the expression with the given data and returns the result as any. +func (e *Expression) Evaluate(ctx context.Context, data map[string]any) (any, error) { + result, _, err := e.prog.ContextEval(ctx, data) + if err != nil { + return nil, fmt.Errorf("failed to evaluate the CEL expression '%s': %w", e.expr, err) + } + return result.Value(), nil +} diff --git a/pkg/cel/expression_test.go b/pkg/cel/expression_test.go new file mode 100644 index 00000000..378ccc98 --- /dev/null +++ b/pkg/cel/expression_test.go @@ -0,0 +1,621 @@ +/* +Copyright 2025 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Copied from: +// https://github.com/fluxcd/pkg/blob/d6af17e6f40bfdd628ab1f7793bc878d5d90e8b6/runtime/cel/expression_test.go + +//nolint:all // Code copied from external project +package cel_test + +import ( + "context" + "testing" + + celgo "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + . "github.com/onsi/gomega" + + "zotregistry.dev/zot/v2/pkg/cel" +) + +func TestNewExpression(t *testing.T) { + for _, tt := range []struct { + name string + expr string + opts []cel.Option + err string + }{ + { + name: "valid expression", + expr: "foo", + }, + { + name: "invalid expression", + expr: "foo.", + err: "failed to parse the CEL expression 'foo.': ERROR: :1:5: Syntax error: no viable alternative at input '.'", + }, + { + name: "compilation detects undeclared references", + expr: "foo", + opts: []cel.Option{cel.WithCompile()}, + err: "failed to parse the CEL expression 'foo': ERROR: :1:1: undeclared reference to 'foo'", + }, + { + name: "compilation detects type errors", + expr: "foo == 'bar'", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + err: "failed to parse the CEL expression 'foo == 'bar'': ERROR: :1:5: found no matching overload for '_==_' applied to '(map(string, dyn), string)'", + }, + { + name: "can't check output type without compiling", + expr: "foo", + opts: []cel.Option{cel.WithOutputType(celgo.BoolType)}, + err: "output type and variables can only be set when compiling the expression", + }, + { + name: "can't declare variables without compiling", + expr: "foo", + opts: []cel.Option{cel.WithStructVariables("foo")}, + err: "output type and variables can only be set when compiling the expression", + }, + { + name: "compilation checks output type", + expr: "'foo'", + opts: []cel.Option{cel.WithCompile(), cel.WithOutputType(celgo.BoolType)}, + err: "CEL expression output type mismatch: expected bool, got string", + }, + { + name: "compilation checking output type can't predict type of struct field", + expr: "foo.bar.baz", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo"), cel.WithOutputType(celgo.BoolType)}, + err: "CEL expression output type mismatch: expected bool, got dyn", + }, + { + name: "compilation checking output type can't predict type of struct field, but if it's a boolean it can be compared to a boolean literal", + expr: "foo.bar.baz == true", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo"), cel.WithOutputType(celgo.BoolType)}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + e, err := cel.NewExpression(tt.expr, tt.opts...) + + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + g.Expect(e).To(BeNil()) + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(e).NotTo(BeNil()) + } + }) + } +} + +func TestExpression_EvaluateBoolean(t *testing.T) { + for _, tt := range []struct { + name string + expr string + opts []cel.Option + data map[string]any + result bool + err string + }{ + { + name: "inexistent field", + expr: "foo", + data: map[string]any{}, + err: "failed to evaluate the CEL expression 'foo': no such attribute(s): foo", + }, + { + name: "boolean field true", + expr: "foo", + data: map[string]any{"foo": true}, + result: true, + }, + { + name: "boolean field false", + expr: "foo", + data: map[string]any{"foo": false}, + result: false, + }, + { + name: "nested boolean field true", + expr: "foo.bar", + data: map[string]any{"foo": map[string]any{"bar": true}}, + result: true, + }, + { + name: "nested boolean field false", + expr: "foo.bar", + data: map[string]any{"foo": map[string]any{"bar": false}}, + result: false, + }, + { + name: "boolean literal true", + expr: "true", + data: map[string]any{}, + result: true, + }, + { + name: "boolean literal false", + expr: "false", + data: map[string]any{}, + result: false, + }, + { + name: "non-boolean literal", + expr: "'some-value'", + data: map[string]any{}, + err: "failed to evaluate CEL expression as boolean: ''some-value''", + }, + { + name: "non-boolean field", + expr: "foo", + data: map[string]any{"foo": "some-value"}, + err: "failed to evaluate CEL expression as boolean: 'foo'", + }, + { + name: "nested non-boolean field", + expr: "foo.bar", + data: map[string]any{"foo": map[string]any{"bar": "some-value"}}, + err: "failed to evaluate CEL expression as boolean: 'foo.bar'", + }, + { + name: "complex expression evaluating true", + expr: "foo && bar", + data: map[string]any{"foo": true, "bar": true}, + result: true, + }, + { + name: "complex expression evaluating false", + expr: "foo && bar", + data: map[string]any{"foo": true, "bar": false}, + result: false, + }, + { + name: "compiled expression returning true", + expr: "foo.bar", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{"foo": map[string]any{"bar": true}}, + result: true, + }, + { + name: "compiled expression returning false", + expr: "foo.bar", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{"foo": map[string]any{"bar": false}}, + result: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + e, err := cel.NewExpression(tt.expr, tt.opts...) + g.Expect(err).NotTo(HaveOccurred()) + + result, err := e.EvaluateBoolean(context.Background(), tt.data) + + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(result).To(Equal(tt.result)) + } + }) + } +} + +func TestExpression_EvaluateString(t *testing.T) { + for _, tt := range []struct { + name string + expr string + opts []cel.Option + data map[string]any + result string + err string + }{ + { + name: "non-existent field", + expr: "foo", + data: map[string]any{}, + err: "failed to evaluate the CEL expression 'foo': no such attribute(s): foo", + }, + { + name: "string field", + expr: "foo", + data: map[string]any{"foo": "some-value"}, + result: "some-value", + }, + { + name: "non-string field", + expr: "foo", + data: map[string]any{"foo": 123}, + err: "failed to evaluate CEL expression as string: 'foo'", + }, + { + name: "nested string field", + expr: "foo.bar", + data: map[string]any{"foo": map[string]any{"bar": "some-value"}}, + result: "some-value", + }, + { + name: "compiled expression returning string", + expr: "foo.bar", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{"foo": map[string]any{"bar": "some-value"}}, + result: "some-value", + }, + { + name: "compiled expression returning string multiple variables", + expr: "foo.bar + '/' + foo.baz + '/' + bar.biz", + opts: []cel.Option{ + cel.WithCompile(), + cel.WithStructVariables("foo", "bar"), + }, + data: map[string]any{ + "foo": map[string]any{ + "bar": "some-value", + "baz": "some-other-value"}, + "bar": map[string]any{ + "biz": "some-third-value", + }, + }, + result: "some-value/some-other-value/some-third-value", + }, + { + name: "compiled expression with string manipulation and zero index", + expr: "foo.bar + '/' + foo.baz + '/' + bar.uid.split('-')[0].lowerAscii()", + opts: []cel.Option{ + cel.WithCompile(), + cel.WithStructVariables("foo", "bar"), + }, + data: map[string]any{ + "foo": map[string]any{ + "bar": "some-value", + "baz": "some-other-value"}, + "bar": map[string]any{ + "uid": "AKS2J23-DAFLSDD-123J5LS", + }, + }, + result: "some-value/some-other-value/aks2j23", + }, + { + name: "compiled expression with string manipulation and first", + expr: "foo.bar + '/' + foo.baz + '/' + bar.uid.split('-').first().value().lowerAscii()", + opts: []cel.Option{ + cel.WithCompile(), + cel.WithStructVariables("foo", "bar"), + }, + data: map[string]any{ + "foo": map[string]any{ + "bar": "some-value", + "baz": "some-other-value"}, + "bar": map[string]any{ + "uid": "AKS2J23-DAFLSDD-123J5LS", + }, + }, + result: "some-value/some-other-value/aks2j23", + }, + { + name: "compiled expression with first", + expr: "foo.bar.split('-').first().value()", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{ + "foo": map[string]any{"bar": "hello-world-testing-123"}, + }, + result: "hello", + }, + { + name: "compiled expression with last", + expr: "foo.bar.split('-').last().value()", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{ + "foo": map[string]any{"bar": "hello-world-testing-123"}, + }, + result: "123", + }, + { + name: "error without value method", + expr: "foo.bar.split('-').first()", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{ + "foo": map[string]any{"bar": "hello-world-testing-123"}, + }, + err: "failed to evaluate CEL expression as string: 'foo.bar.split('-').first()'", + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + e, err := cel.NewExpression(tt.expr, tt.opts...) + g.Expect(err).NotTo(HaveOccurred()) + + result, err := e.EvaluateString(context.Background(), tt.data) + + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(result).To(Equal(tt.result)) + } + }) + } +} + +func TestExpression_EvaluateStringSlice(t *testing.T) { + for _, tt := range []struct { + name string + expr string + opts []cel.Option + data map[string]any + result []string + err string + }{ + { + name: "non-existent field", + expr: "foo", + data: map[string]any{}, + err: "failed to evaluate the CEL expression 'foo': no such attribute(s): foo", + }, + { + name: "string slice field", + expr: "foo", + data: map[string]any{"foo": []string{"value1", "value2", "value3"}}, + result: []string{"value1", "value2", "value3"}, + }, + { + name: "empty string slice field", + expr: "foo", + data: map[string]any{"foo": []string{}}, + result: []string{}, + }, + { + name: "non-slice field", + expr: "foo", + data: map[string]any{"foo": "not-a-slice"}, + err: "failed to evaluate CEL expression 'foo' as []string: unsupported native conversion from string to '[]string'", + }, + { + name: "non-string slice field", + expr: "foo", + data: map[string]any{"foo": []int{1, 2, 3}}, + err: "failed to evaluate CEL expression 'foo' as []string: unsupported type conversion from 'int' to string", + }, + { + name: "nested any slice field", + expr: "foo.bar", + data: map[string]any{"foo": map[string]any{"bar": []any{"nested1", "nested2"}}}, + result: []string{"nested1", "nested2"}, + }, + { + name: "string slice literal", + expr: "['literal1', 'literal2', 'literal3']", + data: map[string]any{}, + result: []string{"literal1", "literal2", "literal3"}, + }, + { + name: "compiled expression returning string slice", + expr: "foo.bar", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{"foo": map[string]any{"bar": []string{"compiled1", "compiled2"}}}, + result: []string{"compiled1", "compiled2"}, + }, + { + name: "compiled expression with string manipulation returning slice", + expr: "foo.items.map(item, item.upperAscii())", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{ + "foo": map[string]any{ + "items": []string{"hello", "world", "test"}, + }, + }, + result: []string{"HELLO", "WORLD", "TEST"}, + }, + { + name: "compiled expression with filter returning slice", + expr: "foo.items.filter(item, item.startsWith('t'))", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{ + "foo": map[string]any{ + "items": []string{"hello", "test", "world", "testing"}, + }, + }, + result: []string{"test", "testing"}, + }, + { + name: "compiled expression with split returning slice", + expr: "foo.value.split(',')", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{ + "foo": map[string]any{ + "value": "item1,item2,item3", + }, + }, + result: []string{"item1", "item2", "item3"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + e, err := cel.NewExpression(tt.expr, tt.opts...) + g.Expect(err).NotTo(HaveOccurred()) + + result, err := e.EvaluateStringSlice(context.Background(), tt.data) + + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(result).To(Equal(tt.result)) + } + }) + } +} + +func TestExpression_Evaluate(t *testing.T) { + for _, tt := range []struct { + name string + expr string + opts []cel.Option + data map[string]any + result any + err string + }{ + { + name: "non-existent field", + expr: "foo", + data: map[string]any{}, + err: "failed to evaluate the CEL expression 'foo': no such attribute(s): foo", + }, + { + name: "string slice field", + expr: "foo", + data: map[string]any{"foo": []string{"value1", "value2", "value3"}}, + result: []string{"value1", "value2", "value3"}, + }, + { + name: "empty string slice field", + expr: "foo", + data: map[string]any{"foo": []string{}}, + result: []string{}, + }, + { + name: "non-slice field", + expr: "foo", + data: map[string]any{"foo": "not-a-slice"}, + result: "not-a-slice", + }, + { + name: "non-string slice field", + expr: "foo", + data: map[string]any{"foo": []int{1, 2, 3}}, + result: []int{1, 2, 3}, + }, + { + name: "nested any slice field", + expr: "foo.bar", + data: map[string]any{"foo": map[string]any{"bar": []any{"nested1", "nested2"}}}, + result: []any{"nested1", "nested2"}, + }, + { + name: "string slice literal", + expr: "['literal1', 'literal2', 'literal3']", + data: map[string]any{}, + result: []ref.Val{types.String("literal1"), types.String("literal2"), types.String("literal3")}, + }, + { + name: "compiled expression returning string slice", + expr: "foo.bar", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{"foo": map[string]any{"bar": []string{"compiled1", "compiled2"}}}, + result: []string{"compiled1", "compiled2"}, + }, + { + name: "compiled expression with string manipulation returning slice", + expr: "foo.items.map(item, item.upperAscii())", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{ + "foo": map[string]any{ + "items": []string{"hello", "world", "test"}, + }, + }, + result: []ref.Val{types.String("HELLO"), types.String("WORLD"), types.String("TEST")}, + }, + { + name: "compiled expression with filter returning slice", + expr: "foo.items.filter(item, item.startsWith('t'))", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{ + "foo": map[string]any{ + "items": []string{"hello", "test", "world", "testing"}, + }, + }, + result: []ref.Val{types.String("test"), types.String("testing")}, + }, + { + name: "compiled expression with split returning slice", + expr: "foo.value.split(',')", + opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, + data: map[string]any{ + "foo": map[string]any{ + "value": "item1,item2,item3", + }, + }, + result: []string{"item1", "item2", "item3"}, + }, + { + name: "expression returning a map", + expr: "foo.bar", + data: map[string]any{ + "foo": map[string]any{ + "bar": map[string]any{ + "value": "item1,item2,item3", + }, + }, + }, + result: map[string]any{ + "value": "item1,item2,item3", + }, + }, + { + name: "expression returning a map with a string slice inside", + expr: "foo.bar", + data: map[string]any{ + "foo": map[string]any{ + "bar": map[string]any{ + "value": []string{"item1", "item2", "item3"}, + }, + }, + }, + result: map[string]any{ + "value": []string{"item1", "item2", "item3"}, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + e, err := cel.NewExpression(tt.expr, tt.opts...) + g.Expect(err).NotTo(HaveOccurred()) + + result, err := e.Evaluate(context.Background(), tt.data) + + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(result).To(Equal(tt.result)) + } + }) + } +} diff --git a/pkg/cli/server/config_reloader_test.go b/pkg/cli/server/config_reloader_test.go index 60e73a20..bf531948 100644 --- a/pkg/cli/server/config_reloader_test.go +++ b/pkg/cli/server/config_reloader_test.go @@ -90,7 +90,8 @@ func TestConfigReloader(t *testing.T) { So(string(initialData), ShouldContainSubstring, "configuration settings") // verify authentication methods status messages are present in initial startup verifyAuthenticationLogs(initialData, map[string]bool{ - "bearer authentication": false, + "jwt bearer authentication": false, + "oidc bearer authentication": false, "basic authentication (htpasswd)": true, "basic authentication (LDAP)": false, "basic authentication (API key)": false, @@ -162,7 +163,8 @@ func TestConfigReloader(t *testing.T) { So(string(data), ShouldContainSubstring, "\"Actions\":[\"read\",\"create\",\"update\",\"delete\"]") // verify authentication methods status messages are present verifyAuthenticationLogs(data, map[string]bool{ - "bearer authentication": false, + "jwt bearer authentication": false, + "oidc bearer authentication": false, "basic authentication (htpasswd)": true, "basic authentication (LDAP)": false, "basic authentication (API key)": false, @@ -223,7 +225,8 @@ func TestConfigReloader(t *testing.T) { So(string(initialData), ShouldContainSubstring, "configuration settings") // verify authentication methods status messages are present in initial startup verifyAuthenticationLogs(initialData, map[string]bool{ - "bearer authentication": false, + "jwt bearer authentication": false, + "oidc bearer authentication": false, "basic authentication (htpasswd)": false, "basic authentication (LDAP)": false, "basic authentication (API key)": false, @@ -287,7 +290,8 @@ func TestConfigReloader(t *testing.T) { So(string(data), ShouldNotContainSubstring, "\"Dedupe\":false") // verify authentication methods status messages are present verifyAuthenticationLogs(data, map[string]bool{ - "bearer authentication": false, + "jwt bearer authentication": false, + "oidc bearer authentication": false, "basic authentication (htpasswd)": false, "basic authentication (LDAP)": false, "basic authentication (API key)": false, @@ -359,7 +363,8 @@ func TestConfigReloader(t *testing.T) { So(string(initialData), ShouldContainSubstring, "configuration settings") // verify authentication methods status messages are present in initial startup verifyAuthenticationLogs(initialData, map[string]bool{ - "bearer authentication": false, + "jwt bearer authentication": false, + "oidc bearer authentication": false, "basic authentication (htpasswd)": false, "basic authentication (LDAP)": false, "basic authentication (API key)": false, @@ -435,7 +440,8 @@ func TestConfigReloader(t *testing.T) { So(string(data), ShouldContainSubstring, "\"Semver\":false") // verify authentication methods status messages are present verifyAuthenticationLogs(data, map[string]bool{ - "bearer authentication": false, + "jwt bearer authentication": false, + "oidc bearer authentication": false, "basic authentication (htpasswd)": false, "basic authentication (LDAP)": false, "basic authentication (API key)": false, @@ -500,7 +506,8 @@ func TestConfigReloader(t *testing.T) { So(string(initialData), ShouldContainSubstring, "configuration settings") // verify authentication methods status messages are present in initial startup verifyAuthenticationLogs(initialData, map[string]bool{ - "bearer authentication": false, + "jwt bearer authentication": false, + "oidc bearer authentication": false, "basic authentication (htpasswd)": false, "basic authentication (LDAP)": false, "basic authentication (API key)": false, @@ -566,7 +573,8 @@ func TestConfigReloader(t *testing.T) { So(string(data), ShouldContainSubstring, "\"DBRepository\":\"another/unreachable/trivy/url2\"") // verify authentication methods status messages are present verifyAuthenticationLogs(data, map[string]bool{ - "bearer authentication": false, + "jwt bearer authentication": false, + "oidc bearer authentication": false, "basic authentication (htpasswd)": false, "basic authentication (LDAP)": false, "basic authentication (API key)": false, diff --git a/pkg/cli/server/root.go b/pkg/cli/server/root.go index 1cf6eced..e779b5d7 100644 --- a/pkg/cli/server/root.go +++ b/pkg/cli/server/root.go @@ -717,7 +717,7 @@ func validateAuthzPolicies(config *config.Config, logger zlog.Logger) error { logger.Info().Msg("checking if anonymous authorization is the only type of authorization policy configured") - if !authConfig.IsBasicAuthnEnabled() && !config.IsMTLSAuthEnabled() && + if !authConfig.IsBasicAuthnEnabled() && !config.IsMTLSAuthEnabled() && !authConfig.IsBearerAuthEnabled() && !accessControlConfig.ContainsOnlyAnonymousPolicy() { msg := "access control config requires one of htpasswd, ldap, openid or mTLS authentication " + "or using only 'anonymousPolicy' policies" diff --git a/pkg/cli/server/root_test.go b/pkg/cli/server/root_test.go index 4e60f72f..36ea6244 100644 --- a/pkg/cli/server/root_test.go +++ b/pkg/cli/server/root_test.go @@ -48,7 +48,8 @@ func checkAuthLogEntry(logData []byte, message string, expectedEnabled bool) boo // expectedAuth maps authentication method names to their expected enabled status (true/false). func verifyAuthenticationLogs(data []byte, expectedAuth map[string]bool) { authMethods := []string{ - "bearer authentication", + "jwt bearer authentication", + "oidc bearer authentication", "basic authentication (htpasswd)", "basic authentication (LDAP)", "basic authentication (API key)", @@ -2261,7 +2262,8 @@ func TestServeAPIKey(t *testing.T) { So(string(data), ShouldContainSubstring, "configuration settings") // verify authentication methods status messages are present verifyAuthenticationLogs(data, map[string]bool{ - "bearer authentication": false, + "jwt bearer authentication": false, + "oidc bearer authentication": false, "basic authentication (htpasswd)": false, "basic authentication (LDAP)": false, "basic authentication (API key)": true, @@ -2298,7 +2300,8 @@ func TestServeAPIKey(t *testing.T) { So(string(data), ShouldContainSubstring, "configuration settings") // verify authentication methods status messages are present verifyAuthenticationLogs(data, map[string]bool{ - "bearer authentication": false, + "jwt bearer authentication": false, + "oidc bearer authentication": false, "basic authentication (htpasswd)": false, "basic authentication (LDAP)": false, "basic authentication (API key)": false,