From ddc1493d338725b1153ef27b6e12ad4f06addbf1 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sat, 19 Sep 2026 11:45:03 +0200 Subject: [PATCH 01/14] feat(auth): support SPIFFE JWT-SVID authentication via sub-claim fallback --- .mise/tasks/test/spire-jwt-svid | 274 ++++++++++++++++++ CHANGELOG/feat_spiffe_jwt_svid_auth.md | 32 ++ .../config/command_line/idp_auth_config.rs | 35 +++ .../server/src/config/params/server_params.rs | 10 + crate/server/src/config/wizard/auth_wizard.rs | 12 + crate/server/src/main.rs | 4 + .../server/src/middlewares/jwt/jwt_config.rs | 5 + .../src/middlewares/jwt/jwt_token_auth.rs | 222 ++++++++++++-- crate/server/src/middlewares/mod.rs | 2 + crate/server/src/routes/google_cse/jwt.rs | 10 + crate/server/src/start_kms_server.rs | 1 + crate/server/src/tests/google_cse/mod.rs | 3 + crate/server/src/tests/google_cse/utils.rs | 1 + ...26-09-19-spiffe-jwt-svid-authentication.md | 168 +++++++++++ .../docs/configuration/log-reference.md | 2 + .../docs/configuration/server_cli.md | 9 + .../docs/integrations/spire_spiffe.md | 70 +++++ 17 files changed, 838 insertions(+), 22 deletions(-) create mode 100755 .mise/tasks/test/spire-jwt-svid create mode 100644 CHANGELOG/feat_spiffe_jwt_svid_auth.md create mode 100644 documentation/docs/adr/2026-09-19-spiffe-jwt-svid-authentication.md diff --git a/.mise/tasks/test/spire-jwt-svid b/.mise/tasks/test/spire-jwt-svid new file mode 100755 index 0000000000..f140ee9a14 --- /dev/null +++ b/.mise/tasks/test/spire-jwt-svid @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +#MISE description="SPIFFE JWT-SVID end-to-end authentication test via SPIRE + ckms CLI" +#USAGE flag "-v --variant " env="VARIANT" help="FIPS variant" default="non-fips" { +#USAGE choices "fips" "non-fips" +#USAGE } +#USAGE flag "-l --link " env="LINK" help="Linkage type" default="static" { +#USAGE choices "static" "dynamic" +#USAGE } +set -euo pipefail + +source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" +source "${MISE_CONFIG_ROOT}/.mise/lib/kms_server.sh" + +if [ "${usage_variant:-non-fips}" != "non-fips" ]; then + print_error "SPIRE tests require non-fips variant (got: ${usage_variant})" +fi + +kms_init_env "non-fips" "${usage_link:-static}" +setup_test_logging + +REPO_ROOT="$(get_repo_root)" +TEST_DATA="${REPO_ROOT}/test_data/spire" +AUTH_PID="" +AUTH_LOG="/tmp/auth-verifier-spire-jwt-svid.log" +AUTH_DB_FILE="/tmp/auth-verifier-spire-jwt-svid.db" +SPIRE_SECRETS_ENV="/tmp/spire-jwt-svid-secrets.env" +KMS_LOG_FILE="/tmp/kms-spire-jwt-svid.log" +SPIRE_SERVER_CONTAINER="spire-server-a" +TRUST_DOMAIN="cosmian-test-a.local" +WORKLOAD_SPIFFE_ID="spiffe://${TRUST_DOMAIN}/test-workload-app" +AUDIENCE="cosmian-kms" + +listener_pids_for_port() { + lsof -ti tcp:"$1" 2>/dev/null | sort -u || true +} + +port_is_listening() { + lsof -i tcp:"$1" -sTCP:LISTEN 2>/dev/null | grep -q . 2>/dev/null +} + +wait_for_port_closed() { + local port="$1" timeout_secs="$2" elapsed=0 + while port_is_listening "${port}"; do + if [[ "${elapsed}" -ge "${timeout_secs}" ]]; then + return 1 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done +} + +stop_listener_on_port() { + local port="$1" label="$2" pids pid + pids="$(listener_pids_for_port "${port}")" + [[ -z "${pids}" ]] && return 0 + print_info "Stopping stale ${label} listener on port ${port}" + while IFS= read -r pid; do + [[ -n "${pid}" ]] && kill "${pid}" 2>/dev/null || true + done <<<"${pids}" + wait_for_port_closed "${port}" 10 || { + while IFS= read -r pid; do + [[ -n "${pid}" ]] && kill -9 "${pid}" 2>/dev/null || true + done <<<"${pids}" + } +} + +reset_spire_state() { + stop_listener_on_port 8443 "auth-verifier" + stop_listener_on_port 9998 "KMS" + stop_listener_on_port 8088 "jwks-server" + docker compose --profile spire down --volumes --remove-orphans 2>/dev/null || true + rm -f "${AUTH_DB_FILE}" "${AUTH_DB_FILE}-wal" "${AUTH_DB_FILE}-shm" 2>/dev/null || true + rm -f "${SPIRE_SECRETS_ENV}" /tmp/spire-join-token.txt 2>/dev/null || true + rm -rf /tmp/spire-agent-config-* 2>/dev/null || true + rm -f /tmp/spire-server-*.log 2>/dev/null || true +} + +cleanup() { + print_info "Cleaning up SPIRE JWT-SVID test resources..." + [[ -n "${JWKS_SERVER_PID:-}" ]] && kill "${JWKS_SERVER_PID}" 2>/dev/null || true + [[ -n "${AUTH_PID:-}" ]] && kill "${AUTH_PID}" 2>/dev/null || true + [[ -n "${KMS_PID:-}" ]] && kill "${KMS_PID}" 2>/dev/null || true + reset_spire_state +} +trap cleanup EXIT + +require_cmd docker +require_cmd cargo +require_cmd openssl +require_cmd python3 + +if [[ ! -d "${TEST_DATA}" ]]; then + print_error "test_data/spire not found." +fi + +print_status "Resetting SPIRE test state..." +reset_spire_state + +print_header "SPIRE JWT-SVID Authentication Integration Test" + +# ── Step 1: Check / Generate TLS certificates ─────────────────────────────── +print_status "Step 1: TLS certificates..." +if [[ ! -f "${TEST_DATA}/certs/ca.crt" || ! -f "${TEST_DATA}/certs/jwt.key.pem" ]]; then + print_info "Generating test TLS certificates..." + bash "${TEST_DATA}/certs/generate-test-certs.sh" +fi + +# ── Step 2: Build server and CLI ───────────────────────────────────────────── +print_status "Step 2: Building KMS server (non-fips,insecure) & ckms CLI..." +kms_build_server --features non-fips,insecure + +cargo build --manifest-path "${REPO_ROOT}/authentication/Cargo.toml" --bin auth_verifier +AUTH_BIN="$(cd "${REPO_ROOT}/authentication" && cargo metadata --no-deps --format-version 1 | + python3 -c "import sys,json; m=json.load(sys.stdin); print(m['target_directory'])")/debug/auth_verifier" + +kms_build_cli "${FEATURES_FLAG[@]+"${FEATURES_FLAG[@]}"}" + +# ── Step 3: Start auth-verifier ────────────────────────────────────────────── +print_status "Step 3: Starting auth-verifier on port 8443..." +rm -f "${AUTH_DB_FILE}" +"${AUTH_BIN}" "${TEST_DATA}/config/auth_verifier.toml" >"${AUTH_LOG}" 2>&1 & +AUTH_PID=$! +if ! wait_for_port 127.0.0.1 8443 60; then + print_error "auth-verifier failed to start. Check ${AUTH_LOG}" +fi +print_success "auth-verifier ready." + +# ── Step 4: Bootstrap temporary KMS for Root CA & AppRole provisioning ────── +print_status "Step 4: Bootstrapping temporary KMS server for SPIRE PKI..." +KMS_CONFIG_FILE="${TEST_DATA}/config/kms.toml" +"$(get_kms_bin)" --config "${KMS_CONFIG_FILE}" >"${KMS_LOG_FILE}" 2>&1 & +KMS_PID=$! +if ! wait_for_port 127.0.0.1 9998 120; then + print_error "KMS failed to start for bootstrapping. Check ${KMS_LOG_FILE}" +fi + +CKMS_CONF="$(kms_write_ckms_conf "https://localhost:9998")" +_VAULT_EXT_FILE=$(mktemp /tmp/vault_pki_ca_ext.XXXXXX) +cat >"${_VAULT_EXT_FILE}" <<'EXTEOF' +[ v3_ca ] +basicConstraints=critical,CA:TRUE +keyUsage=critical,keyCertSign,crlSign,digitalSignature +EXTEOF + +"$(get_ckms_bin)" --conf-path "${CKMS_CONF}" \ + --accept-invalid-certs \ + certificates certify \ + --generate-key-pair \ + --algorithm nist-p384 \ + --certificate-id vault_pki_ca_cert \ + --subject-name "CN=Cosmian KMS Root CA,O=Cosmian,C=FR" \ + --tag vault_pki_ca \ + --days 3650 \ + --certificate-extensions "${_VAULT_EXT_FILE}" \ + 2>&1 | grep -v "^$" +rm -f "${_VAULT_EXT_FILE}" + +AUTH_VERIFIER_URL="https://localhost:8443" \ + VAULT_ADDR="https://localhost:9998" \ + VAULT_CACERT="${TEST_DATA}/certs/ca.crt" \ + CKMS_BIN="$(get_ckms_bin)" \ + CKMS_CONF="${CKMS_CONF}" \ + SECRETS_ENV_FILE="${SPIRE_SECRETS_ENV}" \ + bash "${TEST_DATA}/setup/provision.sh" + +# shellcheck disable=SC1090 +source "${SPIRE_SECRETS_ENV}" + +# ── Step 5: Start SPIRE server (tenant A) ──────────────────────────────────── +print_status "Step 5: Starting SPIRE server A..." +export SPIRE_SERVER_CONFIG_FILE_A="./test_data/spire/config/spire-server-a.conf" +export VAULT_APPROLE_ID_A="${SPIRE_ROLE_ID_A}" +export VAULT_APPROLE_SECRET_ID_A="${SPIRE_SECRET_ID_A}" +docker compose --profile spire up -d "${SPIRE_SERVER_CONTAINER}" + +tries=0 +until docker inspect --format='{{.State.Health.Status}}' "${SPIRE_SERVER_CONTAINER}" 2>/dev/null | grep -q healthy; do + tries=$((tries + 1)) + if [[ "${tries}" -ge 24 ]]; then + print_error "SPIRE server did not become healthy in time." + fi + sleep 5 +done +print_success "SPIRE server A healthy." + +# ── Step 6: Export SPIRE JWT keys and serve via local JWKS HTTP endpoint ───── +print_status "Step 6: Exporting SPIRE bundle and serving JWKS..." +JWKS_DIR=$(mktemp -d /tmp/spire-jwks-XXXXXX) +JWKS_FILE="${JWKS_DIR}/jwks.json" + +docker exec "${SPIRE_SERVER_CONTAINER}" \ + /opt/spire/bin/spire-server bundle show \ + -socketPath /tmp/spire-server/private/api.sock \ + -format SPIFFE >"${JWKS_FILE}" +print_info "Exported SPIRE JWKS:" +cat "${JWKS_FILE}" + +python3 -m http.server 8088 --directory "${JWKS_DIR}" >/dev/null 2>&1 & +JWKS_SERVER_PID=$! +if ! wait_for_port 127.0.0.1 8088 10; then + print_error "Failed to start JWKS HTTP server on port 8088" +fi +print_success "JWKS HTTP server running on port 8088." + +# ── Step 7: Restart KMS with --jwt-svid-auth and SPIRE JWKS provider ───────── +print_status "Step 7: Restarting KMS with --jwt-svid-auth enabled..." +kill "${KMS_PID}" 2>/dev/null || true +wait "${KMS_PID}" 2>/dev/null || true +KMS_PID="" + +JWT_PROVIDER_SPEC="https://${TRUST_DOMAIN},http://127.0.0.1:8088/jwks.json,${AUDIENCE}" +KMS_SVID_LOG="/tmp/kms-spire-jwt-svid-server.log" + +"$(get_kms_bin)" \ + --config "${KMS_CONFIG_FILE}" \ + --jwt-auth-provider "${JWT_PROVIDER_SPEC}" \ + --jwt-svid-auth \ + >"${KMS_SVID_LOG}" 2>&1 & +KMS_PID=$! + +if ! wait_for_port 127.0.0.1 9998 120; then + print_error "KMS failed to start with --jwt-svid-auth. Check ${KMS_SVID_LOG}" +fi +print_success "KMS server ready with --jwt-svid-auth." + +# ── Step 8: Mint JWT-SVID for workload and configure ckms ───────────────────── +print_status "Step 8: Minting JWT-SVID via spire-server..." +MINT_OUTPUT=$(docker exec "${SPIRE_SERVER_CONTAINER}" \ + /opt/spire/bin/spire-server jwt mint \ + -socketPath /tmp/spire-server/private/api.sock \ + -spiffeID "${WORKLOAD_SPIFFE_ID}" \ + -audience "${AUDIENCE}" \ + -ttl 1h) + +JWT_TOKEN=$(echo "${MINT_OUTPUT}" | awk '/^token:/{print $2}' | tr -d '\r\n') +if [[ -z "${JWT_TOKEN}" ]]; then + # Fallback parse if token format is raw or multiline + JWT_TOKEN=$(echo "${MINT_OUTPUT}" | grep -E '^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$' | head -n1 | tr -d '\r\n') +fi + +if [[ -z "${JWT_TOKEN}" ]]; then + print_error "Failed to extract minted JWT-SVID. Mint output: ${MINT_OUTPUT}" +fi +print_success "JWT-SVID minted successfully for ${WORKLOAD_SPIFFE_ID}." + +print_status "Writing ckms.toml with access_token..." +SVID_CKMS_CONF=$(mktemp /tmp/ckms-svid.toml.XXXXXX) +cat >"${SVID_CKMS_CONF}" <` header. + +Because a SPIFFE JWT-SVID does not carry an `email` claim (identifying the workload +solely via `sub = spiffe:///`), standard OIDC JWT +validation previously rejected these tokens. + +#### Changes + +1. **Opt-in server flag**: `--jwt-svid-auth` / `KMS_JWT_SVID_AUTH` (`IdpAuthConfig.jwt_svid_auth`), + defaulting to `false`. When enabled on the server, JWT authentication allows tokens without an + `email` claim provided `sub` starts with `spiffe://`. +2. **Subject Mapping**: The full SPIFFE URI (e.g. `spiffe://cosmian-test-a.local/my-workload`) + is used directly as the KMS `UserId` / object owner. +3. **Audit Trail**: Authentications via SPIFFE JWT-SVID are tracked as `AuthMethod::JwtSvid` + (distinct from `AuthMethod::OidcJwt`). +4. **Interactive Setup**: `kms setup` auth wizard now prompts whether configured JWT/OIDC + providers issue SPIFFE JWT-SVIDs. +5. **E2E Integration Test**: Added `.mise/tasks/test/spire-jwt-svid` validating the full + mint → `ckms` configuration → object creation and ownership verification flow against a + live SPIRE server. diff --git a/crate/server/src/config/command_line/idp_auth_config.rs b/crate/server/src/config/command_line/idp_auth_config.rs index 1a4df871b0..61326299eb 100644 --- a/crate/server/src/config/command_line/idp_auth_config.rs +++ b/crate/server/src/config/command_line/idp_auth_config.rs @@ -31,6 +31,19 @@ pub struct IdpAuthConfig { /// This argument can be repeated to configure multiple identity providers. #[clap(verbatim_doc_comment, long, env = "KMS_JWT_AUTH_PROVIDER", action = clap::ArgAction::Append)] pub jwt_auth_provider: Option>, + + /// Accept SPIFFE JWT-SVIDs from the configured `--jwt-auth-provider` issuers. + /// + /// A SPIFFE JWT-SVID carries no `email` claim, only a `sub` claim shaped as + /// `spiffe:///`. When this flag is enabled, a JWT that + /// validates successfully (signature, issuer, audience, expiry) against a configured + /// issuer but has no `email` claim is authenticated using its `sub` claim **only if** + /// `sub` starts with `spiffe://`; every other JWT still requires `email` as before. + /// + /// Disabled by default: enabling it only makes sense when the configured issuer(s) are + /// a SPIFFE-aware JWKS source (e.g. a SPIRE OIDC Discovery Provider). + #[clap(long, env = "KMS_JWT_SVID_AUTH")] + pub jwt_svid_auth: bool, } impl IdpAuthConfig { @@ -128,9 +141,31 @@ mod tests { "https://issuer1.com,https://jwks1.com,key1,key2".to_owned(), // Duplicate "https://issuer3.com,,".to_owned(), ]), + jwt_svid_auth: false, }; let extracted = idp_list.extract_idp_configs().unwrap().unwrap(); assert_eq!(extracted.len(), 3); // One duplicate should be removed info!("Extracted IDP Configs: {:#?}", extracted); } + + #[test] + fn jwt_svid_auth_defaults_to_false() { + let idp_auth_config = IdpAuthConfig::default(); + assert!(!idp_auth_config.jwt_svid_auth); + } + + /// `jwt_svid_auth` is a server-wide opt-in flag; it must not influence how + /// `--jwt-auth-provider` entries are parsed/deduplicated (non-regression). + #[test] + #[allow(clippy::unwrap_used)] + fn jwt_svid_auth_does_not_affect_provider_extraction() { + let idp_list = IdpAuthConfig { + jwt_auth_provider: Some(vec![ + "https://issuer1.com,https://jwks1.com,key1".to_owned(), + ]), + jwt_svid_auth: true, + }; + let extracted = idp_list.extract_idp_configs().unwrap().unwrap(); + assert_eq!(extracted.len(), 1); + } } diff --git a/crate/server/src/config/params/server_params.rs b/crate/server/src/config/params/server_params.rs index bdf80112db..1604658951 100644 --- a/crate/server/src/config/params/server_params.rs +++ b/crate/server/src/config/params/server_params.rs @@ -46,6 +46,11 @@ pub struct ServerParams { /// The JWT Config if Auth is enabled pub identity_provider_configurations: Option>, + /// When `true`, JWTs from `identity_provider_configurations` issuers that have no + /// `email` claim are authenticated using their `sub` claim, provided it starts with + /// `spiffe://` (SPIFFE JWT-SVID support). See `IdpAuthConfig::jwt_svid_auth`. + pub jwt_svid_auth_enabled: bool, + /// The UI distribution folder pub ui_index_html_folder: PathBuf, @@ -395,6 +400,9 @@ impl ServerParams { // include it in the CORS allow-list when cors_allowed_origins is not configured. let public_url_for_cors = conf.kms_public_url.clone(); + // Capture before `conf.idp_auth` is consumed by `extract_idp_configs` below. + let jwt_svid_auth_enabled = conf.idp_auth.jwt_svid_auth; + // Determine whether CO users will come from the deprecated `privileged_users` path. // Used after `res` is built to preserve v5.26.0 behaviour: if the operator had // `force_default_username = true` AND `privileged_users = [...]` (nonsensical but @@ -409,6 +417,7 @@ impl ServerParams { .extract_idp_configs() .context("failed initializing IdPs from idp_auth")? }, + jwt_svid_auth_enabled, ui_index_html_folder, ui_enable: conf.ui_config.enable, ui_oidc_auth: conf.ui_config.ui_oidc_auth, @@ -829,6 +838,7 @@ impl fmt::Debug for ServerParams { if let Some(ref idp_configs) = self.identity_provider_configurations { debug_struct.field("identity_provider_configurations", idp_configs); } + debug_struct.field("jwt_svid_auth_enabled", &self.jwt_svid_auth_enabled); // Always show these non-optional fields debug_struct diff --git a/crate/server/src/config/wizard/auth_wizard.rs b/crate/server/src/config/wizard/auth_wizard.rs index 33c8f8099c..37a09692ae 100644 --- a/crate/server/src/config/wizard/auth_wizard.rs +++ b/crate/server/src/config/wizard/auth_wizard.rs @@ -54,6 +54,7 @@ pub fn configure_auth(http: &mut HttpConfig, ui: &mut UiConfig) -> KResult = Vec::new(); + let mut jwt_svid_auth = false; let mut ui_oidc = OidcConfig::default(); let mut auth_verifier = AuthVerifierConfig::default(); @@ -80,6 +81,16 @@ pub fn configure_auth(http: &mut HttpConfig, ui: &mut UiConfig) -> KResult/...), e.g. a \ + SPIRE OIDC Discovery Provider?", + ) + .default(false) + .interact() + .map_err(|e| KmsError::ServerError(format!("Prompt error: {e}")))?; + // UI OIDC let configure_ui_oidc = Confirm::with_theme(&theme) .with_prompt("Configure OIDC for the web UI?") @@ -198,6 +209,7 @@ pub fn configure_auth(http: &mut HttpConfig, ui: &mut UiConfig) -> KResult>, pub jwks: Arc, + /// When `true`, a token from this issuer that has no `email` claim is authenticated + /// using its `sub` claim, provided `sub` starts with `spiffe://` (SPIFFE JWT-SVID). + /// Defaults to `false` so existing OIDC/IdP issuers keep requiring `email`. Only set + /// from the operator-controlled `--jwt-svid-auth` flag; Google CSE issuers never set it. + pub accept_spiffe_subject: bool, } impl JwtConfig { diff --git a/crate/server/src/middlewares/jwt/jwt_token_auth.rs b/crate/server/src/middlewares/jwt/jwt_token_auth.rs index 2f57d1cbc9..fef1813607 100644 --- a/crate/server/src/middlewares/jwt/jwt_token_auth.rs +++ b/crate/server/src/middlewares/jwt/jwt_token_auth.rs @@ -19,6 +19,10 @@ use crate::{ result::KResult, }; +/// URI scheme prefix identifying a SPIFFE ID (e.g. `spiffe://example.org/ns/foo/sa/bar`), +/// as carried by the `sub` claim of a SPIFFE JWT-SVID. +const SPIFFE_ID_PREFIX: &str = "spiffe://"; + /// Attempts to extract and validate a user claim from a JWT token /// /// Tries each provided JWT configuration until one successfully validates the token or all configurations fail. @@ -28,15 +32,19 @@ use crate::{ /// * `token` - The JWT token string /// /// # Returns -/// * `Ok(UserClaim)` - Successfully validated user claim +/// * `Ok((UserClaim, bool))` - Successfully validated user claim, plus the +/// `accept_spiffe_subject` flag of the configuration that validated it /// * `Err(Vec)` - List of errors from failed validation attempts -fn extract_user_claim(configs: &[JwtConfig], token: &str) -> Result> { +fn extract_user_claim( + configs: &[JwtConfig], + token: &str, +) -> Result<(UserClaim, bool), Vec> { let mut jwt_log_errors = Vec::new(); // Try each JWT configuration until one succeeds for idp_config in configs { match idp_config.decode_bearer_header(token) { - Ok(user_claim) => return Ok(user_claim), + Ok(user_claim) => return Ok((user_claim, idp_config.accept_spiffe_subject)), Err(error) => { jwt_log_errors.push(error); } @@ -47,6 +55,51 @@ fn extract_user_claim(configs: &[JwtConfig], token: &str) -> Result KResult { + let spiffe_sub = accept_spiffe_subject + .then(|| { + user_claim + .sub + .filter(|sub| sub.starts_with(SPIFFE_ID_PREFIX)) + }) + .flatten(); + if let Some(email) = user_claim.email { + // Authentication successful with valid email + debug!("JWT Access granted to {email}!"); + let username = UserId::from(email); + reject_reserved_aws_xks_identity(&username)?; + Ok(AuthenticatedUser { + username, + auth_method: AuthMethod::OidcJwt, + }) + } else if let Some(sub) = spiffe_sub { + // SPIFFE JWT-SVID: no email claim, but a validated spiffe:// subject and the + // issuer's config explicitly opted in via `--jwt-svid-auth`. + debug!("JWT-SVID access granted to {sub}!"); + let username = UserId::from(sub); + reject_reserved_aws_xks_identity(&username)?; + Ok(AuthenticatedUser { + username, + auth_method: AuthMethod::JwtSvid, + }) + } else { + // JWT is valid but missing the required email claim (and either SPIFFE JWT-SVID + // support is not enabled for this issuer, or `sub` is not a spiffe:// URI) + warn!("no email in JWT"); + Err(KmsError::InvalidRequest("No email in JWT".to_owned())) + } +} + /// Core JWT authentication logic /// /// Extracts the JWT token from the request, validates it, and checks @@ -95,27 +148,16 @@ pub(super) async fn handle_jwt( private_claim = extract_user_claim(&configs, &identity); } - // Process the validation result and extract the email claim - match private_claim.map(|user_claim| user_claim.email) { - Ok(Some(email)) => { - // Authentication successful with valid email - debug!("JWT Access granted to {email}!"); - let username = UserId::from(email); - reject_reserved_aws_xks_identity(&username)?; - Ok(AuthenticatedUser { - username, - auth_method: AuthMethod::OidcJwt, + match private_claim { + Ok((user_claim, accept_spiffe_subject)) => { + resolve_authenticated_user(user_claim, accept_spiffe_subject).inspect_err(|_| { + warn!( + "{:?} {} 401 unauthorized, no email in JWT", + req.method(), + req.path() + ); }) } - Ok(None) => { - // JWT is valid but missing the required email claim — log as WARN for audit trail - warn!( - "{:?} {} 401 unauthorized, no email in JWT", - req.method(), - req.path() - ); - Err(KmsError::InvalidRequest("No email in JWT".to_owned())) - } Err(jwt_log_errors) => { // JWT validation failed — log at WARN so auth failures appear in production logs for error in &jwt_log_errors { @@ -130,3 +172,139 @@ pub(super) async fn handle_jwt( } } } + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + + use super::{AuthMethod, JwtConfig, UserClaim, extract_user_claim, resolve_authenticated_user}; + use crate::middlewares::{UserId, jwt::JwksManager}; + + fn claim(sub: Option<&str>, email: Option<&str>) -> UserClaim { + UserClaim { + email: email.map(str::to_owned), + iss: None, + sub: sub.map(str::to_owned), + aud: None, + iat: None, + exp: None, + nbf: None, + jti: None, + role: None, + resource_name: None, + perimeter_id: None, + kacls_url: None, + spki_hash: None, + spki_hash_algorithm: None, + message_id: None, + email_type: None, + google_email: None, + } + } + + /// Existing OIDC behaviour must be unaffected: `email` present is always accepted, + /// regardless of `accept_spiffe_subject`. + #[test] + fn email_claim_is_accepted_and_takes_priority() { + let user_claim = claim(Some("spiffe://example.org/ns/foo/sa/bar"), Some("a@b.com")); + let user = resolve_authenticated_user(user_claim, true).expect("must be accepted"); + assert_eq!(user.username, UserId::from("a@b.com")); + assert_eq!(user.auth_method, AuthMethod::OidcJwt); + } + + /// A SPIFFE JWT-SVID (`sub = spiffe://...`, no `email`) is accepted only when + /// `accept_spiffe_subject` is enabled — this is the `--jwt-svid-auth` opt-in flag. + #[test] + fn spiffe_subject_accepted_when_flag_enabled() { + let user_claim = claim(Some("spiffe://example.org/ns/foo/sa/bar"), None); + let user = + resolve_authenticated_user(user_claim, true).expect("SPIFFE sub must be accepted"); + assert_eq!( + user.username, + UserId::from("spiffe://example.org/ns/foo/sa/bar") + ); + assert_eq!(user.auth_method, AuthMethod::JwtSvid); + } + + /// Non-regression: the exact same token must still be rejected when the operator has + /// not enabled `--jwt-svid-auth` for this issuer. + #[test] + fn spiffe_subject_rejected_when_flag_disabled() { + let user_claim = claim(Some("spiffe://example.org/ns/foo/sa/bar"), None); + let error = resolve_authenticated_user(user_claim, false) + .expect_err("must be rejected when accept_spiffe_subject is false"); + assert!(error.to_string().contains("No email in JWT")); + } + + /// A `sub` that is not a SPIFFE ID must never be accepted as a username, even when the + /// flag is enabled — the fallback is strictly scoped to `spiffe://` subjects. + #[test] + fn non_spiffe_subject_rejected_even_when_flag_enabled() { + let user_claim = claim(Some("not-a-spiffe-id"), None); + let error = resolve_authenticated_user(user_claim, true) + .expect_err("non-spiffe sub must never be accepted as a username"); + assert!(error.to_string().contains("No email in JWT")); + } + + /// No `sub` and no `email` must be rejected regardless of the flag. + #[test] + fn no_subject_no_email_rejected() { + let user_claim = claim(None, None); + let error = resolve_authenticated_user(user_claim, true) + .expect_err("must be rejected without email or sub"); + assert!(error.to_string().contains("No email in JWT")); + } + + /// `reject_reserved_aws_xks_identity` is invoked on the resolved SPIFFE username, so any + /// `spiffe://` subject that happened to collide with the reserved identity would still be + /// rejected. `AWS_XKS_SERVICE_USER` itself never starts with `spiffe://`, so this call + /// order is the actual defense-in-depth mechanism (see the dedicated, exhaustive + /// coverage of `reject_reserved_aws_xks_identity` in `middlewares::mod::tests`). + #[test] + fn spiffe_subject_still_goes_through_reserved_identity_check() { + use crate::routes::aws_xks::AWS_XKS_SERVICE_USER; + + // A spiffe:// subject is never equal to the reserved identity, so it is accepted... + let user_claim = claim(Some("spiffe://example.org/ns/foo/sa/bar"), None); + let user = resolve_authenticated_user(user_claim, true).expect("must be accepted"); + assert_ne!(user.username.as_str(), AWS_XKS_SERVICE_USER); + } + + fn sign_test_jwt(claims: &UserClaim) -> String { + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("test-kid".to_owned()); + encode(&header, claims, &EncodingKey::from_secret(b"test-secret")) + .expect("failed to sign test JWT") + } + + async fn empty_jwks_manager() -> JwksManager { + JwksManager::new(vec![], None) + .await + .expect("empty JwksManager must build without network access") + } + + /// `extract_user_claim` must surface the `accept_spiffe_subject` flag of whichever + /// configuration validated the token, so `handle_jwt` can apply the SPIFFE fallback. + #[tokio::test] + async fn extract_user_claim_surfaces_accept_spiffe_subject_flag() { + let jwks = std::sync::Arc::new(empty_jwks_manager().await); + let configs = vec![JwtConfig { + jwt_issuer_uri: "https://issuer.example.com".to_owned(), + jwt_audience: None, + jwks, + accept_spiffe_subject: true, + }]; + let token = sign_test_jwt(&claim(Some("spiffe://example.org/ns/foo/sa/bar"), None)); + + let (user_claim, accept_spiffe_subject) = + extract_user_claim(&configs, &format!("Bearer {token}")) + .expect("token must be decoded in test/insecure mode"); + + assert!(accept_spiffe_subject); + assert_eq!( + user_claim.sub.as_deref(), + Some("spiffe://example.org/ns/foo/sa/bar") + ); + } +} diff --git a/crate/server/src/middlewares/mod.rs b/crate/server/src/middlewares/mod.rs index 4fab599c5c..f17f036f32 100644 --- a/crate/server/src/middlewares/mod.rs +++ b/crate/server/src/middlewares/mod.rs @@ -74,6 +74,8 @@ pub(crate) enum AuthMethod { SpireToken, /// Standard OIDC / `IdP` JWT (with `kid`) OidcJwt, + /// SPIFFE JWT-SVID (no `email` claim; identity taken from `sub = spiffe://...`) + JwtSvid, /// Cosmian Auth Verifier JWT (no `kid`) AuthVerifierJwt, /// Static API token (Bearer) diff --git a/crate/server/src/routes/google_cse/jwt.rs b/crate/server/src/routes/google_cse/jwt.rs index 175001ef43..9298e8a905 100644 --- a/crate/server/src/routes/google_cse/jwt.rs +++ b/crate/server/src/routes/google_cse/jwt.rs @@ -76,6 +76,7 @@ pub fn list_jwt_configurations( // calls `Validation::set_audience`, which makes jsonwebtoken 10.x accept // the token instead of rejecting it with InvalidAudience. jwt_audience: Some(vec!["kacls-migration".to_owned()]), + accept_spiffe_subject: false, }) .collect::>() } @@ -105,6 +106,7 @@ fn jwt_authorization_config_application( jwt_issuer_uri, jwks: jwks_manager, jwt_audience, + accept_spiffe_subject: false, }) } @@ -635,6 +637,7 @@ mod tests { jwt_issuer_uri: issuer.to_owned(), jwks: jwks_manager, jwt_audience: Some(vec!["cse-authorization".to_owned()]), + accept_spiffe_subject: false, }); let now = now_usize(); @@ -700,6 +703,7 @@ mod tests { jwt_issuer_uri: issuer.to_owned(), jwks: jwks_manager, jwt_audience: Some(vec!["cse-authorization".to_owned()]), + accept_spiffe_subject: false, }); let now = now_usize(); @@ -736,6 +740,7 @@ mod tests { jwt_issuer_uri: issuer.to_owned(), jwks: jwks_manager, jwt_audience: None, + accept_spiffe_subject: false, }); let now = now_usize(); @@ -773,6 +778,7 @@ mod tests { jwt_issuer_uri: expected_issuer.to_owned(), jwks: jwks_manager, jwt_audience: Some(vec!["cse-authorization".to_owned()]), + accept_spiffe_subject: false, }); let now = now_usize(); @@ -824,6 +830,7 @@ mod tests { jwt_issuer_uri: kms_a_url.to_owned(), jwks: jwks_manager.clone(), jwt_audience: Some(vec!["kacls-migration".to_owned()]), + accept_spiffe_subject: false, }; let cse_config = super::GoogleCseConfig { @@ -878,6 +885,7 @@ mod tests { jwt_issuer_uri: kms_a_url.to_owned(), jwks: jwks_manager.clone(), jwt_audience: Some(vec!["kacls-migration".to_owned()]), + accept_spiffe_subject: false, }; let cse_config = super::GoogleCseConfig { @@ -947,6 +955,7 @@ mod tests { "{},{},{}", JWT_ISSUER_URI, JWKS_URI, client_id )]), + jwt_svid_auth: false, }; let idp_configs = jwt_authentication_config .extract_idp_configs() @@ -956,6 +965,7 @@ mod tests { jwt_issuer_uri: idp_configs[0].jwt_issuer_uri.clone(), jwks: jwks_manager.clone(), jwt_audience: idp_configs[0].jwt_audience.clone(), + accept_spiffe_subject: false, }; let authentication_token = jwt_authentication_config diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index e2ecee241c..91abe551b5 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -1022,6 +1022,7 @@ pub async fn prepare_kms_server( jwt_issuer_uri: idp_config.jwt_issuer_uri.clone(), jwks: jwks_manager.clone(), jwt_audience: idp_config.jwt_audience.clone(), + accept_spiffe_subject: kms_server.params.jwt_svid_auth_enabled, }) .collect::>(); diff --git a/crate/server/src/tests/google_cse/mod.rs b/crate/server/src/tests/google_cse/mod.rs index 8762684370..820710f9d4 100644 --- a/crate/server/src/tests/google_cse/mod.rs +++ b/crate/server/src/tests/google_cse/mod.rs @@ -883,6 +883,7 @@ async fn test_google_cse_custom_jwt() -> KResult<()> { jwt_issuer_uri: kacls_url.to_owned(), jwt_audience: Some(vec!["kacls-migration".to_owned()]), jwks: Arc::new(jwks_manager), + accept_spiffe_subject: false, }]), authorization: HashMap::new(), }; @@ -974,6 +975,7 @@ async fn test_google_cse_custom_jwt_multi_audience_match() -> KResult<()> { jwt_issuer_uri: kacls_url.to_owned(), jwt_audience: Some(vec!["wrong-aud".to_owned(), "kacls-migration".to_owned()]), jwks: Arc::new(jwks_manager), + accept_spiffe_subject: false, }]), authorization: HashMap::new(), }; @@ -1063,6 +1065,7 @@ async fn test_google_cse_custom_jwt_multi_audience_nomatch() -> KResult<()> { jwt_issuer_uri: kacls_url.to_owned(), jwt_audience: Some(vec!["wrong1".to_owned(), "wrong2".to_owned()]), jwks: Arc::new(jwks_manager), + accept_spiffe_subject: false, }]), authorization: HashMap::new(), }; diff --git a/crate/server/src/tests/google_cse/utils.rs b/crate/server/src/tests/google_cse/utils.rs index 5067b4df72..1b91beb870 100644 --- a/crate/server/src/tests/google_cse/utils.rs +++ b/crate/server/src/tests/google_cse/utils.rs @@ -72,6 +72,7 @@ pub(crate) async fn google_cse_auth( jwt_issuer_uri: GOOGLE_JWT_ISSUER_URI.to_owned(), jwks: jwks_manager.clone(), jwt_audience: None, + accept_spiffe_subject: false, }; Ok(GoogleCseConfig { diff --git a/documentation/docs/adr/2026-09-19-spiffe-jwt-svid-authentication.md b/documentation/docs/adr/2026-09-19-spiffe-jwt-svid-authentication.md new file mode 100644 index 0000000000..9719aa0940 --- /dev/null +++ b/documentation/docs/adr/2026-09-19-spiffe-jwt-svid-authentication.md @@ -0,0 +1,168 @@ +--- +title: "ADR-2026-09-19: SPIFFE JWT-SVID Authentication via JWT `sub`-Claim Fallback" +status: "Accepted" +date: "2026-09-19" +authors: "Architecture Team" +tags: ["architecture", "spiffe", "spire", "jwt", "mtls", "authentication"] +supersedes: "" +superseded_by: "" +--- + +# ADR-2026-09-19: SPIFFE JWT-SVID Authentication via JWT `sub`-Claim Fallback + +## Status + +Accepted + +## Context + +Operators deploying the KMS inside a Kubernetes cluster that uses SPIFFE/SPIRE for +workload identity terminate service-to-service traffic in mTLS, with each workload +authenticating via a JWT-SVID (a JWT issued by the SPIRE OIDC Discovery Provider) rather +than a classic OIDC identity token. + +Two options were initially considered by the operator to reach this deployment shape: + +1. Enable KMS mTLS **and** force `force_default_username = admin` in the server config so + that every authenticated client (whatever its actual workload identity) is mapped to a + single administrative account. This defeats per-workload authorization/audit and is a + security regression. +2. Enable TLS only, with no authentication at all, which removes any application-level + identity and authorization — unacceptable for a KMS. + +Investigation of the existing KMS auth stack showed two real gaps preventing a proper +third option (mTLS as transport only + JWT-SVID as the actual identity): + +- `crate/server/src/middlewares/jwt/jwt_token_auth.rs` required an `email` claim to + authenticate a JWT. A JWT-SVID carries no `email` claim — only `sub = + spiffe:///` (already required and validated by + `validate_authentication_token`, which enforces `required_spec_claims = ["sub", "exp"]`). +- The existing mTLS middleware (`tls_auth.rs`) only reads the certificate CN, not the + SPIFFE SAN URI — but this ADR does not extend it (see Alternatives). + +The KMS already supports configuring an arbitrary OIDC-style JWT issuer via +`--jwt-auth-provider`, and a SPIRE OIDC Discovery Provider exposes a standard +`/.well-known/openid-configuration` + JWKS endpoint, so no new provider integration is +needed — only the claim-to-identity mapping logic needed to change, and only when the +operator explicitly opts in. + +## Decision + +Introduce an explicit, per-deployment opt-in flag, `--jwt-svid-auth` / +`KMS_JWT_SVID_AUTH` (`IdpAuthConfig.jwt_svid_auth`), applied globally to all +`--jwt-auth-provider` entries. When enabled, the JWT authentication middleware accepts a +validated token that has **no** `email` claim, provided its `sub` claim starts with +`spiffe://` (a SPIFFE ID). The full SPIFFE URI is used, unmodified, as the KMS `UserId` +(no truncation), which becomes the acting principal for every subsequent authorization +check. + +mTLS, when enabled, remains strictly a **transport-level** control: the peer certificate is +verified against the SPIFFE trust bundle via the existing `client_ca_cert_pem` mechanism, +but its content (CN or SAN) is **not** used to derive application identity. +`tls_auth.rs` is intentionally left unmodified. All application identity for this flow +comes from the JWT-SVID's `sub` claim, keeping a single source of truth for "who is +calling" regardless of whether mTLS or plain TLS is used at the transport level. + +Priority order in the JWT middleware is: `email` (existing OIDC/IdP behavior, unchanged) +first; `sub` (SPIFFE-only fallback, opt-in) second; otherwise reject with the pre-existing +"no email in JWT" error. This preserves 100% backward compatibility for every existing +JWT/OIDC and Google CSE configuration, none of which set the new flag. + +The Web UI and `ckms` CLI are unaffected: `ckms` already supports configuring a +pre-obtained bearer token (`access_token` in `ckms.toml`), which is exactly how an operator +supplies a JWT-SVID minted via `spire-server jwt mint`. No CLI or UI code changes are +required; self-service SPIFFE login from the browser UI remains a known, documented +limitation, out of scope for this decision. + +## Consequences + +### Positive + +- **POS-001**: Operators can run the KMS as a proper SPIFFE-aware workload — mTLS for + transport-level trust, JWT-SVID for per-workload identity and authorization — without + collapsing all traffic onto a single shared `admin` account. +- **POS-002**: Zero behavioral change for existing OIDC/IdP and Google CSE deployments: the + fallback is strictly opt-in per flag and additionally scoped to `sub` values that are + syntactically SPIFFE IDs. +- **POS-003**: No new provider/protocol integration was required — the SPIRE OIDC + Discovery Provider is consumed through the existing generic `--jwt-auth-provider` + mechanism. +- **POS-004**: `ckms` CLI and existing `access_token` configuration work unchanged, keeping + the CLI/UI parity rule (`cli-ui-sync.instructions.md`) satisfied without additional + development. + +### Negative + +- **NEG-001**: The full SPIFFE URI becomes the KMS username; operators relying on + human-readable usernames for audit/reporting will see SPIFFE URIs instead (mitigated by + documenting this mapping clearly; no truncation/aliasing is performed, by design, to + avoid silently colliding two distinct workload identities). +- **NEG-002**: There is still no self-service SPIFFE login path for the Web UI; UI users + must continue to authenticate via the existing supported methods. This is a known, + documented limitation, not a defect of this decision. +- **NEG-003**: The JWT middleware now carries an additional branch (SPIFFE `sub` fallback), + slightly increasing its cyclomatic complexity; mitigated by extracting the decision logic + into a small, independently unit-tested pure function + (`resolve_authenticated_user`). + +## Alternatives Considered + +### Extend `tls_auth.rs` to read the SPIFFE SAN URI from the client certificate + +- **ALT-001 Description**: Have the mTLS middleware itself extract the SPIFFE ID from the + certificate's SAN URI and use it as the KMS identity, instead of relying on the JWT. +- **ALT-002 Rejection Reason**: The operator's deployment explicitly separates transport + trust (mTLS) from application identity (JWT-SVID), matching how the SPIRE Workload API + and the cluster's existing token-minting flow are actually used. Using the certificate as + the identity source would create two divergent identity paths (cert-based vs + JWT-based) depending on which auth method wins, complicating the audit trail. Kept as a + documented non-goal. + +### Force `force_default_username = admin` when mTLS is enabled + +- **ALT-003 Description**: Map every mTLS-authenticated client to a single shared `admin` + account, as the operator was initially forced to do. +- **ALT-004 Rejection Reason**: Eliminates per-workload authorization and audit trail — + unacceptable from a least-privilege and traceability standpoint; the entire motivation + for this ADR was to avoid this workaround. + +### TLS only, no authentication + +- **ALT-005 Description**: Terminate TLS without any authentication middleware. +- **ALT-006 Rejection Reason**: Removes all application-level identity; not viable for a + KMS handling key material and cryptographic operations. + +## Implementation Notes + +- **IMP-001**: New `AuthMethod::JwtSvid` variant distinguishes SPIFFE JWT-SVID + authentication from standard OIDC JWT (`AuthMethod::OidcJwt`) in audit logs. +- **IMP-002**: `JwtConfig.accept_spiffe_subject: bool` is carried per JWT provider + configuration (not globally), so only providers derived from + `--jwt-auth-provider` + `--jwt-svid-auth` are affected; Google CSE's internally + constructed `JwtConfig` entries always set it to `false`. +- **IMP-003**: `resolve_authenticated_user` (pure function, no HTTP/actix dependency) holds + the identity-resolution decision and is covered by unit tests in + `jwt_token_auth.rs` (email priority, SPIFFE acceptance/rejection, non-SPIFFE + `sub` rejection, reserved-identity defense-in-depth). +- **IMP-004**: A local, non-Kubernetes integration test + (`.mise/tasks/test/spire-jwt-svid`, reusing the existing `test_data/spire/` / + `.mise/tasks/test/spire*` harness of local SPIRE server/agent processes) validates the + end-to-end flow: mint a JWT-SVID via `spire-server jwt mint`, configure it as + `access_token` in `ckms.toml`, and verify the resulting KMS object owner is the full + SPIFFE URI. +- **IMP-005**: Wizard (`auth_wizard.rs`) prompts operators configuring a JWT/OIDC provider + whether it issues SPIFFE JWT-SVIDs, populating `jwt_svid_auth` accordingly. + +## References + +- **REF-001**: `documentation/docs/adr/2026-07-26-spire-spiffe-via-vault-api.md` — related + but distinct SPIRE/SPIFFE integration (KMS as Vault-compatible backend *for* SPIRE + itself, not KMS-as-a-SPIFFE-workload authentication). +- **REF-002**: `crate/server/src/middlewares/jwt/jwt_token_auth.rs`, + `crate/server/src/middlewares/jwt/jwt_config.rs`, + `crate/server/src/config/command_line/idp_auth_config.rs`, + `crate/server/src/config/params/server_params.rs`, + `crate/server/src/start_kms_server.rs`, + `crate/server/src/config/wizard/auth_wizard.rs`. +- **REF-003**: SPIFFE JWT-SVID specification — + diff --git a/documentation/docs/configuration/log-reference.md b/documentation/docs/configuration/log-reference.md index 5ae150ee38..d3c4a347c1 100644 --- a/documentation/docs/configuration/log-reference.md +++ b/documentation/docs/configuration/log-reference.md @@ -746,6 +746,8 @@ Crate path: `crate/server` | `error` | `AuditFileStore: cannot acquire audit log lock {} ({e}) — retrying` | `src/core/audit/file_store.rs` | `e` | - | | `trace` | `Extractable: {:?}` | `src/core/operations/attributes/add.rs` | - | - | | `trace` | `Set Attribute: Extractable: {:?}` | `src/core/operations/attributes/set.rs` | - | - | +| `warn` | `no email in JWT` | `src/middlewares/jwt/jwt_token_auth.rs` | - | Emitted when a validated JWT has no email claim and `--jwt-svid-auth` is not enabled or sub is not a valid SPIFFE ID | +| `debug` | `JWT-SVID access granted to {sub}!` | `src/middlewares/jwt/jwt_token_auth.rs` | `sub`: SPIFFE ID (URI) from JWT sub claim | Workload authenticated via SPIFFE JWT-SVID with full URI mapped to KMS UserId | | `debug` | `DeriveKey asymmetric operation completed successfully` | `src/core/operations/derive_key.rs` | - | Emitted after a non-FIPS X25519 ECDH `DeriveKey` request has validated both referenced keys, derived the shared secret, and persisted the resulting `SecretData` object. | | `warn` | `[kms-init] Failed to seed kms.keys.active.count: {e}` | `src/core/kms/mod.rs` | `e` | - | | `warn` | `[metrics-cron] Failed to sync kms.keys.active.count: {}` | `src/cron.rs` | - | - | diff --git a/documentation/docs/configuration/server_cli.md b/documentation/docs/configuration/server_cli.md index 22bd959381..0bd15205c4 100644 --- a/documentation/docs/configuration/server_cli.md +++ b/documentation/docs/configuration/server_cli.md @@ -372,6 +372,15 @@ Options: [env: KMS_JWT_AUTH_PROVIDER=] + --jwt-svid-auth + Accept SPIFFE JWT-SVIDs from the configured `--jwt-auth-provider` issuers. + + A SPIFFE JWT-SVID carries no `email` claim, only a `sub` claim shaped as `spiffe:///`. When this flag is enabled, a JWT that validates successfully (signature, issuer, audience, expiry) against a configured issuer but has no `email` claim is authenticated using its `sub` claim **only if** `sub` starts with `spiffe://`; every other JWT still requires `email` as before. + + Disabled by default: enabling it only makes sense when the configured issuer(s) are a SPIFFE-aware JWKS source (e.g. a SPIRE OIDC Discovery Provider). + + [env: KMS_JWT_SVID_AUTH=] + --enable Disable the embedded web UI. When set to false, the UI HTML assets are not served and all `/ui/` routes return 404 diff --git a/documentation/docs/integrations/spire_spiffe.md b/documentation/docs/integrations/spire_spiffe.md index 755ab244d6..c83d998979 100644 --- a/documentation/docs/integrations/spire_spiffe.md +++ b/documentation/docs/integrations/spire_spiffe.md @@ -1456,3 +1456,73 @@ numbered scenario; all are asserted **live** against a running KMS + auth-verifi - `test_data/spire/setup/kms_setup.sh` — Bash script that runs all provisioning steps in one shot (`ROLE_NAME=my-spire bash test_data/spire/setup/kms_setup.sh`). - `crate/server/documentation/openapi.yaml` — OpenAPI schema for the `/v1/transit/*` and `/v1//*` paths. - `ckms vault approle --help` — full CLI reference for AppRole provisioning. + +--- + +## Workload Authentication via SPIFFE JWT-SVID + +In addition to acting as a Vault-compatible backend for SPIRE itself (described above), +Eviden KMS natively supports **authenticating application workloads using SPIFFE JWT-SVIDs**. + +Workloads deployed in Kubernetes clusters with SPIRE can establish mTLS connections at +the transport level and present a JWT-SVID as an `Authorization: Bearer ` token +to authenticate to the KMS. + +### Architecture & Claim Mapping + +- **Transport Layer (mTLS)**: Validates client certificates against the cluster's CA bundle + via `client_ca_cert_pem` / `clients_ca_cert_file`. Certificate subject (CN/SAN) is **not** + used for application identity. +- **Application Identity (JWT-SVID)**: Standard JWT-SVIDs do not carry an `email` claim; they + identify the workload via `sub = spiffe:///`. +- **Opt-In Flag (`--jwt-svid-auth` / `jwt_svid_auth = true`)**: When enabled, the KMS JWT + authentication middleware accepts tokens with no `email` claim provided `sub` begins with + `spiffe://`. The full SPIFFE URI is used as the KMS `UserId` / object owner. + +### KMS Server Configuration + +Add the SPIRE OIDC Discovery Provider endpoint (or JWKS endpoint) to `[idp_auth]` in `kms.toml`: + +```toml +[idp_auth] +jwt_auth_provider = [ + "https://oidc-discovery.spire.local,https://oidc-discovery.spire.local/keys,cosmian-kms" +] +jwt_svid_auth = true +``` + +Or via CLI flags / environment variables: + +```bash +cosmian_kms_server \ + --jwt-auth-provider "https://oidc-discovery.spire.local,https://oidc-discovery.spire.local/keys,cosmian-kms" \ + --jwt-svid-auth +``` + +### Workload CLI Usage (`ckms`) + +1. Mint a JWT-SVID for the workload using SPIRE: + + ```bash + spire-server jwt mint \ + -spiffeID spiffe://cosmian-test-a.local/my-workload \ + -audience cosmian-kms + ``` + +2. Configure `ckms.toml` to use the minted token: + + ```toml + [http_config] + server_url = "https://kms.example.com:9998" + access_token = "" + ``` + +3. Run `ckms` commands — all created keys and accesses will be owned by `spiffe://cosmian-test-a.local/my-workload`: + + ```bash + ckms sym keys create my-key + ckms access-rights owned + ``` + +> **Note on Web UI**: Self-service SPIFFE login from browser UI is not supported; the Web UI +> continues to use standard OIDC/user authentication. From c639eb67679a71356533f9b6e2504f0e582bf167 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Sat, 19 Sep 2026 12:04:16 +0200 Subject: [PATCH 02/14] ci(test): trigger pull request CI workflow From d135ba30c3b24fab5648ab333c0d881ed8e85f9f Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 21 Sep 2026 06:32:25 +0200 Subject: [PATCH 03/14] feat(auth): add SPIRE JWT-SVID Web UI login and E2E verification --- .mise/tasks/test/spire-jwt-svid | 120 +++++++++++++++++- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 11 ++ .../src/middlewares/jwt/jwt_token_auth.rs | 21 +++ crate/server/src/middlewares/jwt/mod.rs | 1 + crate/server/src/middlewares/mod.rs | 4 +- crate/server/src/routes/ui_auth.rs | 48 ++++++- crate/server/src/start_kms_server.rs | 7 +- docker-compose.yml | 27 ++++ .../docs/configuration/log-reference.md | 3 +- .../docs/integrations/spire_spiffe.md | 86 ++++++++++--- test_data | 2 +- ui/src/App.tsx | 8 +- ui/src/components/layout/MainLayout.tsx | 2 +- ui/src/i18n/locales/en/layout.json | 3 + ui/src/i18n/locales/fr/layout.json | 3 + ui/src/i18n/locales/zh-CN/layout.json | 3 + ui/src/pages/LoginPage.tsx | 52 +++++++- ui/src/utils/utils.ts | 27 +++- ui/tests/e2e/spiffe-jwt-svid-auth.spec.ts | 62 +++++++++ ui/tests/e2e/spiffe-ui-login.spec.ts | 49 +++++++ 21 files changed, 503 insertions(+), 38 deletions(-) create mode 100644 ui/tests/e2e/spiffe-jwt-svid-auth.spec.ts create mode 100644 ui/tests/e2e/spiffe-ui-login.spec.ts diff --git a/.mise/tasks/test/spire-jwt-svid b/.mise/tasks/test/spire-jwt-svid index f140ee9a14..96cc89ce35 100755 --- a/.mise/tasks/test/spire-jwt-svid +++ b/.mise/tasks/test/spire-jwt-svid @@ -211,10 +211,21 @@ KMS_PID="" JWT_PROVIDER_SPEC="https://${TRUST_DOMAIN},http://127.0.0.1:8088/jwks.json,${AUDIENCE}" KMS_SVID_LOG="/tmp/kms-spire-jwt-svid-server.log" +# Generate config TOML with [idp_auth] because --config takes precedence and ignores CLI args +KMS_SVID_CONFIG_FILE=$(mktemp /tmp/kms-svid-conf.toml.XXXXXX) +cat "${KMS_CONFIG_FILE}" >"${KMS_SVID_CONFIG_FILE}" +cat >>"${KMS_SVID_CONFIG_FILE}" <"${KMS_SVID_LOG}" 2>&1 & KMS_PID=$! @@ -222,7 +233,6 @@ if ! wait_for_port 127.0.0.1 9998 120; then print_error "KMS failed to start with --jwt-svid-auth. Check ${KMS_SVID_LOG}" fi print_success "KMS server ready with --jwt-svid-auth." - # ── Step 8: Mint JWT-SVID for workload and configure ckms ───────────────────── print_status "Step 8: Minting JWT-SVID via spire-server..." MINT_OUTPUT=$(docker exec "${SPIRE_SERVER_CONTAINER}" \ @@ -269,6 +279,108 @@ if echo "${OWNED_OUTPUT}" | grep -q "${TEST_KEY_ID}"; then else print_error "Key '${TEST_KEY_ID}' not found in list-owned output: ${OWNED_OUTPUT}" fi +# ── Step 10: Run Playwright E2E test with JWT-SVID bearer token ─────────────── +print_status "Step 10: Running Playwright E2E test with JWT-SVID bearer token..." +export PLAYWRIGHT_KMS_URL="https://127.0.0.1:9998" +export TEST_JWT_SVID_TOKEN="${JWT_TOKEN}" +export TEST_SPIFFE_ID="${WORKLOAD_SPIFFE_ID}" + +if (cd "${REPO_ROOT}/ui" && ([ -d node_modules ] || pnpm install --frozen-lockfile) && CI=true pnpm run test:e2e -- spiffe-jwt-svid-auth.spec.ts); then + print_success "Playwright E2E test for SPIFFE JWT-SVID passed." +else + print_error "Playwright E2E test for SPIFFE JWT-SVID failed." +fi + +# ── Step 11: Mint a demo-user JWT-SVID and log into the Web UI with it ──────── +print_status "Step 11: Minting demo-user JWT-SVID and testing Web UI SPIFFE login..." +DEMO_USER_SPIFFE_ID="spiffe://${TRUST_DOMAIN}/webui-demo-user" +DEMO_MINT_OUTPUT=$(docker exec "${SPIRE_SERVER_CONTAINER}" \ + /opt/spire/bin/spire-server jwt mint \ + -socketPath /tmp/spire-server/private/api.sock \ + -spiffeID "${DEMO_USER_SPIFFE_ID}" \ + -audience "${AUDIENCE}" \ + -ttl 1h) + +DEMO_JWT_TOKEN=$(echo "${DEMO_MINT_OUTPUT}" | awk '/^token:/{print $2}' | tr -d '\r\n') +if [[ -z "${DEMO_JWT_TOKEN}" ]]; then + DEMO_JWT_TOKEN=$(echo "${DEMO_MINT_OUTPUT}" | grep -E '^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$' | head -n1 | tr -d '\r\n') +fi +if [[ -z "${DEMO_JWT_TOKEN}" ]]; then + print_error "Failed to extract demo-user JWT-SVID. Mint output: ${DEMO_MINT_OUTPUT}" +fi +print_success "Demo-user JWT-SVID minted for ${DEMO_USER_SPIFFE_ID}." + +export PLAYWRIGHT_KMS_URL="https://127.0.0.1:9998" +export TEST_JWT_SVID_TOKEN="${DEMO_JWT_TOKEN}" +export TEST_SPIFFE_ID="${DEMO_USER_SPIFFE_ID}" + +if (cd "${REPO_ROOT}/ui" && ([ -d node_modules ] || pnpm install --frozen-lockfile) && CI=true pnpm run test:e2e -- spiffe-ui-login.spec.ts); then + print_success "Playwright E2E test for the Web UI SPIFFE login form passed." +else + print_error "Playwright E2E test for the Web UI SPIFFE login form failed." +fi + +# ── Step 12: Validate mTLS + JWT-SVID simultaneously ─────────────────────────── +print_status "Step 12: Validating mTLS + JWT-SVID configured together..." +kill "${KMS_PID}" 2>/dev/null || true +wait "${KMS_PID}" 2>/dev/null || true +KMS_PID="" + +KMS_MTLS_JWT_LOG="/tmp/kms-spire-mtls-jwt-svid.log" +KMS_DUAL_CONFIG_FILE=$(mktemp /tmp/kms-dual-conf.toml.XXXXXX) +sed "/\[tls\]/a\\ +clients_ca_cert_file = \"${TEST_DATA}/certs/ca.crt\" +" "${KMS_CONFIG_FILE}" >"${KMS_DUAL_CONFIG_FILE}" +cat >>"${KMS_DUAL_CONFIG_FILE}" <"${KMS_MTLS_JWT_LOG}" 2>&1 & +KMS_PID=$! + +if ! wait_for_port 127.0.0.1 9998 120; then + print_error "KMS failed to start with both mTLS and --jwt-svid-auth. Check ${KMS_MTLS_JWT_LOG}" +fi +print_success "KMS server ready with both mTLS and --jwt-svid-auth." + +# 1. Test request using JWT-SVID bearer token (no client cert) +TEST_KEY_ID_JWT="spiffe-dual-jwt-key-$(date +%s)" +"$(get_ckms_bin)" --conf-path "${SVID_CKMS_CONF}" --accept-invalid-certs \ + sym keys create "${TEST_KEY_ID_JWT}" +print_success "Key '${TEST_KEY_ID_JWT}' created via JWT-SVID on dual-auth server." + +OWNED_OUTPUT_JWT=$("$(get_ckms_bin)" --conf-path "${SVID_CKMS_CONF}" --accept-invalid-certs \ + access-rights owned) +if echo "${OWNED_OUTPUT_JWT}" | grep -q "${TEST_KEY_ID_JWT}"; then + print_success "Dual auth (JWT-SVID path): Object is owned by ${WORKLOAD_SPIFFE_ID}!" +else + print_error "Key '${TEST_KEY_ID_JWT}' not found in list-owned output: ${OWNED_OUTPUT_JWT}" +fi + +# 2. Test request using mTLS client certificate (no JWT bearer token) +MTLS_CKMS_CONF=$(mktemp /tmp/ckms-mtls.toml.XXXXXX) +cat >"${MTLS_CKMS_CONF}" < KResult { + let mut errors = Vec::new(); + for config in configs.iter().filter(|c| c.accept_spiffe_subject) { + match config.validate_authentication_token(token, true) { + Ok(claim) => return resolve_authenticated_user(claim, true), + Err(e) => errors.push(e), + } + } + for error in &errors { + warn!("{error:?}"); + } + Err(KmsError::InvalidRequest("bad JWT-SVID".to_owned())) +} + /// Core JWT authentication logic /// /// Extracts the JWT token from the request, validates it, and checks diff --git a/crate/server/src/middlewares/jwt/mod.rs b/crate/server/src/middlewares/jwt/mod.rs index be530a0d25..45091023c8 100644 --- a/crate/server/src/middlewares/jwt/mod.rs +++ b/crate/server/src/middlewares/jwt/mod.rs @@ -14,3 +14,4 @@ mod jwt_middleware; pub(crate) use jwt_middleware::jwt_auth_middleware; mod jwt_token_auth; +pub(crate) use jwt_token_auth::validate_jwt_svid; diff --git a/crate/server/src/middlewares/mod.rs b/crate/server/src/middlewares/mod.rs index f17f036f32..233a9b5d28 100644 --- a/crate/server/src/middlewares/mod.rs +++ b/crate/server/src/middlewares/mod.rs @@ -18,7 +18,9 @@ mod ensure_auth; pub(crate) use ensure_auth::ensure_auth_middleware; mod jwt; -pub(crate) use jwt::{JwksManager, JwtConfig, JwtTokenHeaders, UserClaim, jwt_auth_middleware}; +pub(crate) use jwt::{ + JwksManager, JwtConfig, JwtTokenHeaders, UserClaim, jwt_auth_middleware, validate_jwt_svid, +}; mod rate_limiter; pub(crate) use rate_limiter::{RateLimiterConfig, RateLimiterMiddleware}; diff --git a/crate/server/src/routes/ui_auth.rs b/crate/server/src/routes/ui_auth.rs index c27ffc837a..bdc48cc9a8 100644 --- a/crate/server/src/routes/ui_auth.rs +++ b/crate/server/src/routes/ui_auth.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use actix_session::Session; use actix_web::{HttpRequest, HttpResponse, get, post, web}; @@ -11,7 +11,7 @@ use url::Url; use crate::{ config::{AuthVerifierRuntimeConfig, OidcRuntimeConfig}, - middlewares::{UserId, reject_reserved_aws_xks_identity}, + middlewares::{JwtConfig, UserId, reject_reserved_aws_xks_identity, validate_jwt_svid}, }; fn random_b64url(len_bytes: usize) -> Result { @@ -351,6 +351,12 @@ pub(crate) struct AuthVerifierLoginRequest { totp_code: Option, } +/// Request body for `POST /ui/login_svid`. +#[derive(Debug, Deserialize)] +pub(crate) struct JwtSvidLoginRequest { + jwt_svid: String, +} + /// Mirrors the Auth Verifier server's `AuthenticationResult` shape /// (see `authentication/client/src/models/login.rs`). Duplicated here — rather than /// depending on the `authentication` crate — the same way `ckms login cosmian` @@ -538,6 +544,43 @@ pub(crate) async fn login_as( } } +/// SPIFFE JWT-SVID Web UI login. +/// +/// The browser posts a JWT-SVID minted by `spire-server jwt mint` (or obtained +/// via the SPIRE Workload API). Validated against the SPIFFE-enabled JWT +/// issuers configured via `--jwt-auth-provider` + `--jwt-svid-auth` — the same +/// configuration already used for bearer-token API/KMIP authentication. On +/// success the `sub` claim (`spiffe:///`) becomes the +/// session's `user_id`, exactly like the OIDC `callback` and Auth Verifier +/// `login_as` flows above. +#[post("/login_svid")] +pub(crate) async fn login_svid( + session: Session, + body: web::Json, + jwt_configurations: web::Data>>, +) -> HttpResponse { + if !jwt_configurations.iter().any(|c| c.accept_spiffe_subject) { + return HttpResponse::InternalServerError().json( + serde_json::json!({ "error": "SPIFFE JWT-SVID login is not enabled on this server" }), + ); + } + + let authenticated = match validate_jwt_svid(&jwt_configurations, body.jwt_svid.trim()) { + Ok(user) => user, + Err(e) => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({ "error": format!("{e}") })); + } + }; + + if session.insert("user_id", &authenticated.username).is_err() { + return HttpResponse::InternalServerError() + .json(serde_json::json!({ "error": "Failed to store user_id in session" })); + } + + HttpResponse::Ok().json(serde_json::json!({ "next_step": "Authenticated" })) +} + #[get("/whoami")] pub(crate) async fn whoami(session: Session) -> HttpResponse { match session.get::("user_id") { @@ -613,6 +656,7 @@ pub fn configure_auth_routes(cfg: &mut web::ServiceConfig) { cfg.service(login) .service(callback) .service(login_as) + .service(login_svid) .service(whoami) .service(logout) .service(get_auth_method); diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index 91abe551b5..52b52d9e94 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -1636,7 +1636,7 @@ pub async fn prepare_kms_server( // Ordered list of UI login methods, highest priority first. The Web UI // renders the first entry as the primary login action and the rest as // secondary actions (a button when a single alternative exists, a - // dropdown when several do). Priority is JWT > AUTH_VERIFIER > CERT: + // Priority is JWT > SPIFFE > AUTH_VERIFIER > CERT: // the interactive, per-user methods come before the ambient client // certificate probe. AUTH_VERIFIER is only offered when its UI login is // enabled. The singular `auth_method` served by `get_auth_method` is @@ -1645,6 +1645,10 @@ pub async fn prepare_kms_server( if use_jwt_auth { auth_methods.push("JWT".to_owned()); } + let use_jwt_svid_ui_auth = jwt_configurations.iter().any(|c| c.accept_spiffe_subject); + if use_jwt_svid_ui_auth { + auth_methods.push("SPIFFE".to_owned()); + } if use_auth_verifier && kms_server_for_http .params @@ -1700,6 +1704,7 @@ pub async fn prepare_kms_server( let mut auth_routes = web::scope("/ui") .app_data(Data::new(oidc_runtime_config)) .app_data(Data::new(auth_verifier_runtime_config)) + .app_data(Data::new(jwt_configurations.clone())) .app_data(Data::new(kms_public_url.clone())) .app_data(Data::new(ui_index_folder.clone())) .app_data(Data::new(auth_methods)) diff --git a/docker-compose.yml b/docker-compose.yml index b3bbdd7f68..c974be18ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -328,6 +328,7 @@ services: - ${SPIRE_SERVER_CONFIG_FILE_A:-./test_data/spire/config/spire-server-a.conf}:/etc/spire/server/server.conf:ro - ./test_data/spire/certs/ca.crt:/etc/spire/server/ca.crt:ro - spire-data-a:/data/spire-server + - spire-server-socket-a:/tmp/spire-server environment: # AppRole credentials injected by the mise task from /tmp/spire-secrets.env. # SPIRE vault plugin reads VAULT_APPROLE_ID and VAULT_APPROLE_SECRET_ID; @@ -514,6 +515,31 @@ services: - JWT_AUDIENCE=cosmian-kms - MAX_SVID_TTL_SECONDS=7200 + spire-oidc-discovery-provider: + build: + context: ./test_data/spire/oidc-discovery-provider + image: spire-oidc-discovery-provider:1.15.2 + profiles: [spire] + container_name: spire-oidc-discovery-provider + # SPIRE 1.15+ images run as non-root (uid 1000); run as root for socket access. + user: 0:0 + network_mode: host + depends_on: + spire-server-a: + condition: service_healthy + volumes: + - ${SPIRE_OIDC_CONFIG_FILE:-./test_data/spire/config/spire-oidc-discovery-provider.conf}:/etc/spire/oidc-discovery-provider.conf:ro + - spire-server-socket-a:/tmp/spire-server:ro + command: [-config, /etc/spire/oidc-discovery-provider.conf] + healthcheck: + test: + - CMD-SHELL + - wget -qO- http://127.0.0.1:8008/.well-known/openid-configuration >/dev/null 2>&1 + interval: 5s + timeout: 5s + retries: 60 + start_period: 10s + # ── SDS test services (profile: spire-sds) ─────────────────────────────── # Tests PKI-10: deliver certificates to a mesh sidecar via the standard SDS # interface (Envoy Secret Discovery Service). Envoy fetches its X.509-SVID @@ -615,3 +641,4 @@ volumes: spire-data-b: spire-agent-socket-a: spire-agent-socket-b: + spire-server-socket-a: diff --git a/documentation/docs/configuration/log-reference.md b/documentation/docs/configuration/log-reference.md index d3c4a347c1..40770cb21d 100644 --- a/documentation/docs/configuration/log-reference.md +++ b/documentation/docs/configuration/log-reference.md @@ -58,7 +58,7 @@ Crate path: `crate/server` | `warn` | `UI folder invalid or Linux default detected, falling back to: {fallback:#?}` | `src/config/params/server_params.rs` | `fallback`: fallback UI folder path | - | | `warn` | `{:?} {} 401 unauthorized, no email in JWT` | `src/middlewares/jwt/jwt_token_auth.rs` | - | - | | `warn` | `{:?} {} 401 unauthorized: bad JWT` | `src/middlewares/jwt/jwt_token_auth.rs` | - | - | -| `warn` | `{error:?}` | `src/middlewares/jwt/jwt_token_auth.rs` | `error`: error detail | - | +| `warn` | `{error:?}` | `src/middlewares/jwt/jwt_token_auth.rs` | `error`: error detail | ×2 in this file | | `warn` | `{status_code} - {message}` | `src/routes/mod.rs` | `status_code`: HTTP status code
`message`: human-readable message text | - | | `warn` | `{status} - {}` | `src/routes/jose/error.rs` | `status`: HTTP response status | - | | `info` | `AUTHENTICATION token: {:?}` | `src/routes/google_cse/jwt.rs` | - | - | @@ -1417,6 +1417,7 @@ Crate path: `ui/src/` | `debug` | `RsaSign: signature length` | `actions/RSA/RsaSign.tsx` | — | — | | `debug` | `RsaVerify: dataBuf len` | `actions/RSA/RsaVerify.tsx` | — | — | | `error` | `Auth Verifier login failed:` | `pages/LoginPage.tsx` | - | - | +| `error` | `SPIFFE JWT-SVID login failed:` | `pages/LoginPage.tsx` | - | - | --- diff --git a/documentation/docs/integrations/spire_spiffe.md b/documentation/docs/integrations/spire_spiffe.md index c83d998979..b59db374a3 100644 --- a/documentation/docs/integrations/spire_spiffe.md +++ b/documentation/docs/integrations/spire_spiffe.md @@ -1464,24 +1464,35 @@ numbered scenario; all are asserted **live** against a running KMS + auth-verifi In addition to acting as a Vault-compatible backend for SPIRE itself (described above), Eviden KMS natively supports **authenticating application workloads using SPIFFE JWT-SVIDs**. -Workloads deployed in Kubernetes clusters with SPIRE can establish mTLS connections at -the transport level and present a JWT-SVID as an `Authorization: Bearer ` token -to authenticate to the KMS. +Workloads running in Kubernetes clusters or bare-metal environments with SPIRE can authenticate +to KMS endpoints using standard bearer token semantics (`Authorization: Bearer `). + +### Operator Decision Tree + +```text +Do you want workload authentication via SPIFFE? +├── Option A: mTLS Client Certificate Authentication +│ ├── Uses X.509 SVID (spire-agent / Envoy mTLS) +│ └── Identity is mapped from certificate Common Name (CN). +├── Option B: JWT-SVID Bearer Authentication +│ ├── Uses SPIRE OIDC Discovery Provider / JWKS endpoint +│ ├── Identity is mapped from `sub` claim (`spiffe:///`) +│ └── Requires `jwt_svid_auth = true` (--jwt-svid-auth). +└── Option C: Dual Layer (mTLS Transport + JWT-SVID Application) + ├── Server configures both `[tls] clients_ca_cert_file` and `[idp_auth] jwt_svid_auth` + └── Transport TLS handshake validates CA cert; Bearer header authenticates workload SPIFFE ID. +``` ### Architecture & Claim Mapping -- **Transport Layer (mTLS)**: Validates client certificates against the cluster's CA bundle - via `client_ca_cert_pem` / `clients_ca_cert_file`. Certificate subject (CN/SAN) is **not** - used for application identity. -- **Application Identity (JWT-SVID)**: Standard JWT-SVIDs do not carry an `email` claim; they - identify the workload via `sub = spiffe:///`. -- **Opt-In Flag (`--jwt-svid-auth` / `jwt_svid_auth = true`)**: When enabled, the KMS JWT - authentication middleware accepts tokens with no `email` claim provided `sub` begins with - `spiffe://`. The full SPIFFE URI is used as the KMS `UserId` / object owner. +- **Standard OIDC vs. SPIFFE JWT-SVID**: Standard OIDC/IdP tokens carry an `email` claim. SPIFFE JWT-SVIDs contain no `email` claim; they identify workloads via `sub = spiffe:///`. +- **Opt-In Flag (`--jwt-svid-auth` / `jwt_svid_auth = true`)**: When enabled, the KMS JWT authentication middleware accepts tokens with no `email` claim provided `sub` begins with `spiffe://`. The full SPIFFE URI is used as the KMS `UserId` / object owner. ### KMS Server Configuration -Add the SPIRE OIDC Discovery Provider endpoint (or JWKS endpoint) to `[idp_auth]` in `kms.toml`: +#### 1. JWT-SVID Only + +In `kms.toml`: ```toml [idp_auth] @@ -1491,12 +1502,21 @@ jwt_auth_provider = [ jwt_svid_auth = true ``` -Or via CLI flags / environment variables: +#### 2. Dual Configuration (mTLS + JWT-SVID) -```bash -cosmian_kms_server \ - --jwt-auth-provider "https://oidc-discovery.spire.local,https://oidc-discovery.spire.local/keys,cosmian-kms" \ - --jwt-svid-auth +In `kms.toml`: + +```toml +[tls] +tls_cert_file = "/etc/kms/kms.crt" +tls_key_file = "/etc/kms/kms.key" +clients_ca_cert_file = "/etc/kms/spire-ca.crt" + +[idp_auth] +jwt_auth_provider = [ + "https://oidc-discovery.spire.local,https://oidc-discovery.spire.local/keys,cosmian-kms" +] +jwt_svid_auth = true ``` ### Workload CLI Usage (`ckms`) @@ -1517,12 +1537,38 @@ cosmian_kms_server \ access_token = "" ``` -3. Run `ckms` commands — all created keys and accesses will be owned by `spiffe://cosmian-test-a.local/my-workload`: +3. Run `ckms` commands — all created keys and accesses are owned by `spiffe://cosmian-test-a.local/my-workload`: ```bash ckms sym keys create my-key ckms access-rights owned ``` -> **Note on Web UI**: Self-service SPIFFE login from browser UI is not supported; the Web UI -> continues to use standard OIDC/user authentication. +### HTTP / REST API Usage + +Workloads can authenticate directly to KMIP or REST endpoints via `Authorization: Bearer `: + +```bash +# Check authenticated identity +curl -k -H "Authorization: Bearer ${JWT_SVID}" https://kms.example.com:9998/me + +# Response: +# {"user":"spiffe://cosmian-test-a.local/my-workload"} +``` + +### Expected Log Messages + +- When JWT-SVID authentication succeeds: + + ```text + [DEBUG] cosmian_kms_server::middlewares::jwt::jwt_token_auth: JWT-SVID Access granted to spiffe://cosmian-test-a.local/my-workload! + ``` + +- When client certificate authentication succeeds: + + ```text + [TRACE] cosmian_kms_server::middlewares::tls_auth: Client certificate common name: spire-client + ``` + +> **Note on Web UI**: The Web UI supports SPIFFE JWT-SVID login via a dedicated login form +> (`POST /ui/login_svid`) when `--jwt-svid-auth` is enabled. diff --git a/test_data b/test_data index 44c741d7df..3d86d135d7 160000 --- a/test_data +++ b/test_data @@ -1 +1 @@ -Subproject commit 44c741d7dfd9718fa5876a06c5b7a2f47e254f9d +Subproject commit 3d86d135d7d54075dc5566de882553edd8406a54 diff --git a/ui/src/App.tsx b/ui/src/App.tsx index fc85ee1bf3..474f6c2ce9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -190,9 +190,11 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm // certificate prompt. Only if there is no session do we probe the cert. const sessionMethod: AuthMethod = methods.includes("JWT") ? "JWT" - : methods.includes("AUTH_VERIFIER") - ? "AUTH_VERIFIER" - : undefined; + : methods.includes("SPIFFE") + ? "SPIFFE" + : methods.includes("AUTH_VERIFIER") + ? "AUTH_VERIFIER" + : undefined; if (sessionMethod) { const data = await fetchWhoAmI(location); diff --git a/ui/src/components/layout/MainLayout.tsx b/ui/src/components/layout/MainLayout.tsx index fe76a033d4..b440889617 100644 --- a/ui/src/components/layout/MainLayout.tsx +++ b/ui/src/components/layout/MainLayout.tsx @@ -133,7 +133,7 @@ const MainLayout: React.FC = ({ isDarkMode, setIsDarkMode, auth )} - {authMethod === "JWT" || authMethod === "AUTH_VERIFIER" ? ( + {authMethod === "JWT" || authMethod === "AUTH_VERIFIER" || authMethod === "SPIFFE" ? (
{userId && ( diff --git a/ui/src/i18n/locales/en/layout.json b/ui/src/i18n/locales/en/layout.json index cf81ce15a8..b755fdb2ed 100644 --- a/ui/src/i18n/locales/en/layout.json +++ b/ui/src/i18n/locales/en/layout.json @@ -36,6 +36,9 @@ "oidc": "OIDC", "certificate": "Client certificate", "authVerifier": "Username & password", + "spiffe": "SPIFFE JWT-SVID", + "spiffeSvidPlaceholder": "Paste your SPIFFE JWT-SVID", + "spiffeLoginFailed": "SPIFFE JWT-SVID login failed", "accessKms": "ACCESS KMS", "certErrorDescription": "No client certificate was provided or it is invalid. If the problem persists, close all instances of your browser and relaunch with the correct client certificate previously loaded." } diff --git a/ui/src/i18n/locales/fr/layout.json b/ui/src/i18n/locales/fr/layout.json index e3df5c025c..a27419a20e 100644 --- a/ui/src/i18n/locales/fr/layout.json +++ b/ui/src/i18n/locales/fr/layout.json @@ -36,6 +36,9 @@ "oidc": "OIDC", "certificate": "Certificat client", "authVerifier": "Nom d'utilisateur et mot de passe", + "spiffe": "SPIFFE JWT-SVID", + "spiffeSvidPlaceholder": "Collez votre JWT-SVID SPIFFE", + "spiffeLoginFailed": "Échec de la connexion SPIFFE JWT-SVID", "accessKms": "ACCÉDER AU KMS", "certErrorDescription": "Aucun certificat client n'a été fourni ou il est invalide. Si le problème persiste, fermez toutes les instances de votre navigateur et relancez-le avec le certificat client correct préalablement chargé." } diff --git a/ui/src/i18n/locales/zh-CN/layout.json b/ui/src/i18n/locales/zh-CN/layout.json index ab574ae3d8..58e4ec66f5 100644 --- a/ui/src/i18n/locales/zh-CN/layout.json +++ b/ui/src/i18n/locales/zh-CN/layout.json @@ -36,6 +36,9 @@ "oidc": "OIDC", "certificate": "客户端证书", "authVerifier": "用户名与密码", + "spiffe": "SPIFFE JWT-SVID", + "spiffeSvidPlaceholder": "粘贴您的 SPIFFE JWT-SVID", + "spiffeLoginFailed": "SPIFFE JWT-SVID 登录失败", "accessKms": "访问 KMS", "certErrorDescription": "未提供客户端证书或证书无效。如果问题持续存在,请关闭浏览器的所有实例,并使用之前加载的正确客户端证书重新启动。" } diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx index b87afb0eea..acc3d5f48c 100644 --- a/ui/src/pages/LoginPage.tsx +++ b/ui/src/pages/LoginPage.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/useAuth"; import { useBranding } from "../contexts/useBranding"; -import { AuthMethod, getNoTTLVRequest, loginAuthVerifier } from "../utils/utils"; +import { AuthMethod, getNoTTLVRequest, loginAuthVerifier, loginJwtSvid } from "../utils/utils"; interface LoginProps { auth: boolean; @@ -18,7 +18,9 @@ interface LoginProps { const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthenticated }) => { // Keep only browser-login methods, preserving the server's priority order. - const methods = (authMethods ?? []).filter((m): m is AuthMethod => m === "JWT" || m === "AUTH_VERIFIER" || m === "CERT"); + const methods = (authMethods ?? []).filter( + (m): m is AuthMethod => m === "JWT" || m === "AUTH_VERIFIER" || m === "CERT" || m === "SPIFFE", + ); const [selectedMethod, setSelectedMethod] = useState(methods[0]); const [isLoading, setIsLoading] = useState(false); const [certError, setCertError] = useState(null); @@ -27,6 +29,8 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe const [authVerifierTotpCode, setAuthVerifierTotpCode] = useState(""); const [authVerifierTotpRequired, setAuthVerifierTotpRequired] = useState(false); const [authVerifierError, setAuthVerifierError] = useState(null); + const [jwtSvid, setJwtSvid] = useState(""); + const [svidError, setSvidError] = useState(null); const { login, serverUrl } = useAuth(); const navigate = useNavigate(); const branding = useBranding(); @@ -40,6 +44,8 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe return t("login.certificate"); case "AUTH_VERIFIER": return t("login.authVerifier"); + case "SPIFFE": + return t("login.spiffe"); default: return method ?? ""; } @@ -87,6 +93,8 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe void handleLogin(); } else if (method === "CERT") { void handleAccessKms(); + } else if (method === "SPIFFE") { + setSelectedMethod("SPIFFE"); } else { setSelectedMethod("AUTH_VERIFIER"); } @@ -120,6 +128,22 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe } }; + const handleJwtSvidLogin = async () => { + try { + setIsLoading(true); + setSvidError(null); + await loginJwtSvid(serverUrl, jwtSvid); + // Full page navigation so the app's auth bootstrap re-runs and picks up + // the session cookie the server just set (same pattern as AUTH_VERIFIER). + window.location.assign("/ui/locate"); + } catch (err) { + console.error("SPIFFE JWT-SVID login failed:", err); + setSvidError(err instanceof Error ? err.message : String(err)); + } finally { + setIsLoading(false); + } + }; + return (
{/* Background Image */} @@ -192,6 +216,30 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe {authVerifierTotpRequired ? t("login.verifyCode") : t("login.login")}
+ ) : selectedMethod === "SPIFFE" ? ( +
+ {svidError && ( + + )} + setJwtSvid(e.target.value)} + data-testid="spiffe-svid-input" + /> + +
) : selectedMethod === "JWT" ? (
@@ -152,8 +152,8 @@ const MainLayout: React.FC = ({ isDarkMode, setIsDarkMode, auth )} {onCertLogout && ( - )} diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx index acc3d5f48c..b87afb0eea 100644 --- a/ui/src/pages/LoginPage.tsx +++ b/ui/src/pages/LoginPage.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/useAuth"; import { useBranding } from "../contexts/useBranding"; -import { AuthMethod, getNoTTLVRequest, loginAuthVerifier, loginJwtSvid } from "../utils/utils"; +import { AuthMethod, getNoTTLVRequest, loginAuthVerifier } from "../utils/utils"; interface LoginProps { auth: boolean; @@ -18,9 +18,7 @@ interface LoginProps { const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthenticated }) => { // Keep only browser-login methods, preserving the server's priority order. - const methods = (authMethods ?? []).filter( - (m): m is AuthMethod => m === "JWT" || m === "AUTH_VERIFIER" || m === "CERT" || m === "SPIFFE", - ); + const methods = (authMethods ?? []).filter((m): m is AuthMethod => m === "JWT" || m === "AUTH_VERIFIER" || m === "CERT"); const [selectedMethod, setSelectedMethod] = useState(methods[0]); const [isLoading, setIsLoading] = useState(false); const [certError, setCertError] = useState(null); @@ -29,8 +27,6 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe const [authVerifierTotpCode, setAuthVerifierTotpCode] = useState(""); const [authVerifierTotpRequired, setAuthVerifierTotpRequired] = useState(false); const [authVerifierError, setAuthVerifierError] = useState(null); - const [jwtSvid, setJwtSvid] = useState(""); - const [svidError, setSvidError] = useState(null); const { login, serverUrl } = useAuth(); const navigate = useNavigate(); const branding = useBranding(); @@ -44,8 +40,6 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe return t("login.certificate"); case "AUTH_VERIFIER": return t("login.authVerifier"); - case "SPIFFE": - return t("login.spiffe"); default: return method ?? ""; } @@ -93,8 +87,6 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe void handleLogin(); } else if (method === "CERT") { void handleAccessKms(); - } else if (method === "SPIFFE") { - setSelectedMethod("SPIFFE"); } else { setSelectedMethod("AUTH_VERIFIER"); } @@ -128,22 +120,6 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe } }; - const handleJwtSvidLogin = async () => { - try { - setIsLoading(true); - setSvidError(null); - await loginJwtSvid(serverUrl, jwtSvid); - // Full page navigation so the app's auth bootstrap re-runs and picks up - // the session cookie the server just set (same pattern as AUTH_VERIFIER). - window.location.assign("/ui/locate"); - } catch (err) { - console.error("SPIFFE JWT-SVID login failed:", err); - setSvidError(err instanceof Error ? err.message : String(err)); - } finally { - setIsLoading(false); - } - }; - return (
{/* Background Image */} @@ -216,30 +192,6 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe {authVerifierTotpRequired ? t("login.verifyCode") : t("login.login")}
- ) : selectedMethod === "SPIFFE" ? ( -
- {svidError && ( - - )} - setJwtSvid(e.target.value)} - data-testid="spiffe-svid-input" - /> - -
) : selectedMethod === "JWT" ? ( + ) : authMethods?.includes("SPIFFE") ? ( + ) : null} {/* Secondary action(s): nothing for a single method, a button for diff --git a/ui/tests/e2e/spiffe-ui-login.spec.ts b/ui/tests/e2e/spiffe-ui-login.spec.ts index 2830fdb5bb..96a71e1d6e 100644 --- a/ui/tests/e2e/spiffe-ui-login.spec.ts +++ b/ui/tests/e2e/spiffe-ui-login.spec.ts @@ -1,10 +1,13 @@ /** - * SPIFFE JWT-SVID — Web UI browser login E2E test. + * SPIFFE JWT-SVID — Web UI session E2E test. * - * Validates that a user can paste a SPIRE-issued JWT-SVID into the Web UI's - * "SPIFFE JWT-SVID" login form (POST /ui/login_svid) and reach the - * authenticated application, with the session identity resolved to the - * SPIFFE ID carried by the token's `sub` claim. + * The Web UI has no SPIFFE login form: the session is established by a gateway that + * posts a JWT-SVID to `POST /ui/login_svid`. This test plays the gateway (the request + * shares its cookie jar with the browser context), then loads the SPA and checks that + * it resolves the session identity to the SPIFFE ID carried by the token's `sub` claim. + * + * Requires a real UI build served by the KMS (`ui/dist`); a placeholder index.html + * cannot render the SPA. */ import { expect, test } from "@playwright/test"; @@ -12,38 +15,32 @@ const KMS_URL = process.env.PLAYWRIGHT_KMS_URL ?? "https://127.0.0.1:9998"; const JWT_SVID_TOKEN = process.env.TEST_JWT_SVID_TOKEN; const EXPECTED_SPIFFE_ID = process.env.TEST_SPIFFE_ID ?? "spiffe://cosmian-test-a.local/webui-demo-user"; -test.describe("SPIFFE JWT-SVID Web UI login", () => { +test.describe("SPIFFE JWT-SVID Web UI session", () => { test.skip(!JWT_SVID_TOKEN, "TEST_JWT_SVID_TOKEN environment variable not set"); - test("logs in via the SPIFFE JWT-SVID form and reaches the authenticated UI", async ({ page }) => { - await page.goto(`${KMS_URL}/ui/login`, { waitUntil: "domcontentloaded" }); - - // The SPIFFE method may be primary (form shown directly) or secondary - // (behind a button or dropdown), depending on server auth_methods order. - const spiffeForm = page.getByTestId("spiffe-login-form"); - const secondaryBtn = page.getByTestId("login-secondary-btn"); - const secondaryDropdown = page.getByTestId("login-secondary-dropdown"); - - // Wait for at least one auth control to render - await Promise.race([ - spiffeForm.waitFor({ state: "visible" }), - secondaryBtn.waitFor({ state: "visible" }), - secondaryDropdown.waitFor({ state: "visible" }), - ]).catch(() => {}); + test("advertises the SPIFFE auth method", async ({ request }) => { + const response = await request.get(`${KMS_URL}/ui/auth_method`, { ignoreHTTPSErrors: true }); + expect(response.status()).toBe(200); + const data = await response.json(); + expect(data.auth_methods).toContain("SPIFFE"); + }); - if (!(await spiffeForm.isVisible().catch(() => false))) { - if (await secondaryBtn.isVisible().catch(() => false)) { - await secondaryBtn.click(); - } else if (await secondaryDropdown.isVisible().catch(() => false)) { - await secondaryDropdown.click(); - await page.getByRole("menuitem", { name: /SPIFFE/i }).click(); - } - } + test("rejects an invalid JWT-SVID on /ui/login_svid", async ({ page }) => { + const response = await page.request.post(`${KMS_URL}/ui/login_svid`, { + data: { jwt_svid: `${JWT_SVID_TOKEN}tampered` }, + ignoreHTTPSErrors: true, + }); + expect(response.status()).toBe(401); + }); - await page.getByTestId("spiffe-svid-input").fill(JWT_SVID_TOKEN!); - await page.getByTestId("spiffe-login-submit").click(); + test("gateway-established session is picked up by the UI", async ({ page }) => { + const login = await page.request.post(`${KMS_URL}/ui/login_svid`, { + data: { jwt_svid: JWT_SVID_TOKEN }, + ignoreHTTPSErrors: true, + }); + expect(login.status()).toBe(200); - await page.waitForURL(/\/ui\/locate/); + await page.goto(`${KMS_URL}/ui/locate`, { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("session-user-tag")).toContainText(EXPECTED_SPIFFE_ID); }); }); From 4e550bf43b1db667809a5a2a3af45169682de8f8 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 28 Sep 2026 23:36:16 +0200 Subject: [PATCH 09/14] test(ui): make SPIFFE login spec independent of JWT validation mode --- .mise/tasks/test/spire-jwt-svid | 2 +- ui/tests/e2e/spiffe-ui-login.spec.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.mise/tasks/test/spire-jwt-svid b/.mise/tasks/test/spire-jwt-svid index 2a0c1387d3..de03210352 100755 --- a/.mise/tasks/test/spire-jwt-svid +++ b/.mise/tasks/test/spire-jwt-svid @@ -324,7 +324,7 @@ done print_success "SPIRE server A healthy." # ── Step 6: Export the SPIRE trust bundle and serve it as an HTTPS JWKS ────── -print_status "Step 6: Exporting SPIRE bundle and serving the JWKS over HTTPS..." +print_status "Step 6: Exporting SPIRE bundle and serving the JWKS (${JWKS_URI})..." JWKS_DIR="${WORK_DIR}/jwks" mkdir -p "${JWKS_DIR}" JWKS_FILE="${JWKS_DIR}/jwks.json" diff --git a/ui/tests/e2e/spiffe-ui-login.spec.ts b/ui/tests/e2e/spiffe-ui-login.spec.ts index 96a71e1d6e..8687dfacc1 100644 --- a/ui/tests/e2e/spiffe-ui-login.spec.ts +++ b/ui/tests/e2e/spiffe-ui-login.spec.ts @@ -25,9 +25,11 @@ test.describe("SPIFFE JWT-SVID Web UI session", () => { expect(data.auth_methods).toContain("SPIFFE"); }); - test("rejects an invalid JWT-SVID on /ui/login_svid", async ({ page }) => { + test("rejects a malformed JWT-SVID on /ui/login_svid", async ({ page }) => { const response = await page.request.post(`${KMS_URL}/ui/login_svid`, { - data: { jwt_svid: `${JWT_SVID_TOKEN}tampered` }, + // Not a JWT at all: rejected whatever the validation mode (signature/audience checks + // are covered by the Rust unit tests and the Linux run of the mise suite). + data: { jwt_svid: "not-a-jwt" }, ignoreHTTPSErrors: true, }); expect(response.status()).toBe(401); From 04d2860b9d55071816b887d9e1eea3b88900fa2e Mon Sep 17 00:00:00 2001 From: Manuthor Date: Mon, 28 Sep 2026 23:38:53 +0200 Subject: [PATCH 10/14] test(ui): gate SPIFFE tampered-signature spec on strict validation mode --- .mise/tasks/test/spire-jwt-svid | 3 +++ ui/tests/e2e/spiffe-ui-login.spec.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/.mise/tasks/test/spire-jwt-svid b/.mise/tasks/test/spire-jwt-svid index de03210352..2a83744cd4 100755 --- a/.mise/tasks/test/spire-jwt-svid +++ b/.mise/tasks/test/spire-jwt-svid @@ -601,6 +601,9 @@ print_status "Step 10: Running Playwright E2E tests with JWT-SVID..." export PLAYWRIGHT_KMS_URL="https://127.0.0.1:9998" export TEST_JWT_SVID_TOKEN="${JWT_TOKEN}" export TEST_SPIFFE_ID="${WORKLOAD_SPIFFE_ID}" +if [[ "${STRICT}" -eq 1 ]]; then + export TEST_JWT_STRICT=1 +fi # The UI lockfile requires pnpm >= 10, which is not pre-installed on CI runners # used by the spire job. Bootstrap it from npm when it is missing or too old. diff --git a/ui/tests/e2e/spiffe-ui-login.spec.ts b/ui/tests/e2e/spiffe-ui-login.spec.ts index 8687dfacc1..50f8e491ae 100644 --- a/ui/tests/e2e/spiffe-ui-login.spec.ts +++ b/ui/tests/e2e/spiffe-ui-login.spec.ts @@ -35,6 +35,19 @@ test.describe("SPIFFE JWT-SVID Web UI session", () => { expect(response.status()).toBe(401); }); + // Only meaningful when the KMS really verifies signatures (strict mode, exported by the + // mise task on Linux/CI); an `insecure` build decodes tokens without checking them. + test("rejects a JWT-SVID with a tampered signature", async ({ page }) => { + test.skip(!process.env.TEST_JWT_STRICT, "TEST_JWT_STRICT not set (KMS built without signature validation)"); + const [header, payload, signature] = JWT_SVID_TOKEN!.split("."); + const tampered = `${header}.${payload}.${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; + const response = await page.request.post(`${KMS_URL}/ui/login_svid`, { + data: { jwt_svid: tampered }, + ignoreHTTPSErrors: true, + }); + expect(response.status()).toBe(401); + }); + test("gateway-established session is picked up by the UI", async ({ page }) => { const login = await page.request.post(`${KMS_URL}/ui/login_svid`, { data: { jwt_svid: JWT_SVID_TOKEN }, From bb1c00af91821cd49099ee20cecdc28c54a93f5e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 20:43:10 +0000 Subject: [PATCH 11/14] fix(auth): address review findings on SPIFFE JWT-SVID authentication - /ui/login_svid: renew the session ID before storing user_id to prevent session fixation. - Require a non-empty trust domain in SPIFFE subjects (reject bare `spiffe://` and `spiffe:///path`). - handle_jwt: log the actual rejection reason instead of always "no email in JWT" (e.g. missing `aud` on an SVID). - pre-commit: drop duplicate `ui/src/i18n/locales/fr/` typos exclude. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nw1fHzCJJQUGNM3URi77gK --- .pre-commit-config.yaml | 2 +- .../src/middlewares/jwt/jwt_token_auth.rs | 24 +++++++++++++------ crate/server/src/routes/ui_auth.rs | 3 +++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bc00fcd6bc..2cda32b9ed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: rev: v1.31.1 hooks: - id: typos - exclude: ui/src/i18n/locales/fr/|ui/src/i18n/locales/zh-CN/|documentation/docs/images/google_cse.drawio.svg|crate/test_server/src/test_jwt.rs|crate/pkcs11/documentation/veracrypt_ckms.svg|crate/server/src/tests/google_cse/|documentation/docs/pkcs11/images|documentation/docs/kms_clients/pkcs11/images|crate/server/resources|documentation/docs/algorithms.md|crate/server/src/tests/certificates/chain/root/ca/|documentation/docs/pki/smime.md|documentation/docs/hsms/proteccio.md|crate/crypto/src/crypto/rsa/ckm_rsa_aes_key_wrap.rs|crate/clients/ckms/src/tests/shared/export_import.rs|ui/src/Locate.tsx|kmip/|.mise/scripts/oracle/README_HSM.md|crate/pkcs11/documentation/veracrypt_ckms.svg|documentation/docs/pkcs11/images|nix/signing-keys/cosmian-kms-public.asc|sbom/|documentation/docs/certifications_and_compliance/cryptographic_algorithms/benchmarks/|crate/clients/clap/src/tests/shared/export_import.rs|crate/crypto/src/crypto/fpe/ff1.rs|documentation/docs/benchmarks/|.mise/scripts/bench/bench_run_flamegraph.sh|docs.instructions.md|crate/server/src/tests/jose/rfc_vectors.rs|ui/src/i18n/locales/fr/ + exclude: ui/src/i18n/locales/fr/|ui/src/i18n/locales/zh-CN/|documentation/docs/images/google_cse.drawio.svg|crate/test_server/src/test_jwt.rs|crate/pkcs11/documentation/veracrypt_ckms.svg|crate/server/src/tests/google_cse/|documentation/docs/pkcs11/images|documentation/docs/kms_clients/pkcs11/images|crate/server/resources|documentation/docs/algorithms.md|crate/server/src/tests/certificates/chain/root/ca/|documentation/docs/pki/smime.md|documentation/docs/hsms/proteccio.md|crate/crypto/src/crypto/rsa/ckm_rsa_aes_key_wrap.rs|crate/clients/ckms/src/tests/shared/export_import.rs|ui/src/Locate.tsx|kmip/|.mise/scripts/oracle/README_HSM.md|crate/pkcs11/documentation/veracrypt_ckms.svg|documentation/docs/pkcs11/images|nix/signing-keys/cosmian-kms-public.asc|sbom/|documentation/docs/certifications_and_compliance/cryptographic_algorithms/benchmarks/|crate/clients/clap/src/tests/shared/export_import.rs|crate/crypto/src/crypto/fpe/ff1.rs|documentation/docs/benchmarks/|.mise/scripts/bench/bench_run_flamegraph.sh|docs.instructions.md|crate/server/src/tests/jose/rfc_vectors.rs # ── Whitespace / line-endings ───────────────────────────────────────── - repo: https://github.com/Lucas-C/pre-commit-hooks diff --git a/crate/server/src/middlewares/jwt/jwt_token_auth.rs b/crate/server/src/middlewares/jwt/jwt_token_auth.rs index 7dcf0869cc..f638d64b94 100644 --- a/crate/server/src/middlewares/jwt/jwt_token_auth.rs +++ b/crate/server/src/middlewares/jwt/jwt_token_auth.rs @@ -23,6 +23,14 @@ use crate::{ /// as carried by the `sub` claim of a SPIFFE JWT-SVID. const SPIFFE_ID_PREFIX: &str = "spiffe://"; +/// `true` when `sub` is a SPIFFE ID: the `spiffe://` scheme followed by a non-empty trust +/// domain (a bare `spiffe://` or `spiffe:///path` never names a workload). +fn is_spiffe_id(sub: &str) -> bool { + sub.strip_prefix(SPIFFE_ID_PREFIX) + .and_then(|rest| rest.split('/').next()) + .is_some_and(|trust_domain| !trust_domain.is_empty()) +} + /// Attempts to extract and validate a user claim from a JWT token /// /// Tries each provided JWT configuration until one successfully validates the token or all configurations fail. @@ -68,7 +76,7 @@ fn spiffe_authenticated_user(user_claim: &UserClaim) -> KResult { - resolve_authenticated_user(&user_claim, accept_spiffe_subject).inspect_err(|_| { + resolve_authenticated_user(&user_claim, accept_spiffe_subject).inspect_err(|error| { warn!( - "{:?} {} 401 unauthorized, no email in JWT", + "{:?} {} 401 unauthorized: {error}", req.method(), req.path() ); @@ -338,9 +346,11 @@ mod tests { /// flag is enabled — the fallback is strictly scoped to `spiffe://` subjects. #[test] fn non_spiffe_subject_rejected_even_when_flag_enabled() { - let error = resolve_authenticated_user(&svid_claim("not-a-spiffe-id"), true) - .expect_err("non-spiffe sub must never be accepted as a username"); - assert!(error.to_string().contains("No email in JWT")); + for sub in ["not-a-spiffe-id", "spiffe://", "spiffe:///no-trust-domain"] { + let error = resolve_authenticated_user(&svid_claim(sub), true) + .expect_err("non-spiffe sub must never be accepted as a username"); + assert!(error.to_string().contains("No email in JWT"), "{sub}"); + } } /// No `sub` and no `email` must be rejected regardless of the flag. diff --git a/crate/server/src/routes/ui_auth.rs b/crate/server/src/routes/ui_auth.rs index b83eadb2a6..79123656b8 100644 --- a/crate/server/src/routes/ui_auth.rs +++ b/crate/server/src/routes/ui_auth.rs @@ -573,6 +573,9 @@ pub(crate) async fn login_svid( } }; + // Issue a fresh session ID on login so a session ID planted before authentication + // (session fixation) never becomes an authenticated session. + session.renew(); if session.insert("user_id", &authenticated.username).is_err() { return HttpResponse::InternalServerError() .json(serde_json::json!({ "error": "Failed to store user_id in session" })); From ff3b488f712ce78f931b110864f8e8aceb50e21b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 20:52:00 +0000 Subject: [PATCH 12/14] test(auth): avoid JSON indexing in SPIFFE real-validation tests `clippy::indexing_slicing` rejects `claims["aud"] = ...` under `cargo clippy --tests -D warnings`; use a small `set_claim` helper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nw1fHzCJJQUGNM3URi77gK --- crate/server/src/middlewares/jwt/jwt_token_auth.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crate/server/src/middlewares/jwt/jwt_token_auth.rs b/crate/server/src/middlewares/jwt/jwt_token_auth.rs index f638d64b94..629e102fb6 100644 --- a/crate/server/src/middlewares/jwt/jwt_token_auth.rs +++ b/crate/server/src/middlewares/jwt/jwt_token_auth.rs @@ -550,6 +550,14 @@ mod real_validation { }) } + /// Overwrite one claim of a claim set built by [`valid_claims`]. + fn set_claim(claims: &mut Value, name: &str, value: Value) { + claims + .as_object_mut() + .expect("object") + .insert(name.to_owned(), value); + } + fn sign(key: &TestKey, kid: &str, alg: Algorithm, claims: &Value) -> String { let mut header = Header::new(alg); header.kid = Some(kid.to_owned()); @@ -579,7 +587,7 @@ mod real_validation { let key = generate_key(); let config = config(&key).await; let mut claims = valid_claims(); - claims["aud"] = json!(["some-other-service"]); + set_claim(&mut claims, "aud", json!(["some-other-service"])); let token = sign(&key, KID, Algorithm::ES256, &claims); accept(&config, &token).expect_err("wrong audience must be rejected"); } @@ -601,7 +609,7 @@ mod real_validation { let key = generate_key(); let config = config(&key).await; let mut claims = valid_claims(); - claims["exp"] = json!(now() - 3600); + set_claim(&mut claims, "exp", json!(now() - 3600)); let token = sign(&key, KID, Algorithm::ES256, &claims); accept(&config, &token).expect_err("expired SVID must be rejected"); } @@ -611,7 +619,7 @@ mod real_validation { let key = generate_key(); let config = config(&key).await; let mut claims = valid_claims(); - claims["iss"] = json!("https://evil.example.org"); + set_claim(&mut claims, "iss", json!("https://evil.example.org")); let token = sign(&key, KID, Algorithm::ES256, &claims); accept(&config, &token).expect_err("wrong issuer must be rejected"); } From 7f17624b8079ef50c2a6c28bb831b4f41c7c9f40 Mon Sep 17 00:00:00 2001 From: Manuthor Date: Wed, 30 Sep 2026 07:31:54 +0200 Subject: [PATCH 13/14] ci: filter jobs --- .github/workflows/main_base.yml | 31 ----- .github/workflows/nightly.yml | 19 ++- .github/workflows/release.yml | 19 ++- .github/workflows/test_all.yml | 167 ++++++++++++++++++++----- .github/workflows/update-log-index.yml | 81 ++++++++++++ 5 files changed, 251 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/update-log-index.yml diff --git a/.github/workflows/main_base.yml b/.github/workflows/main_base.yml index 3efe4a7868..6203c549d9 100644 --- a/.github/workflows/main_base.yml +++ b/.github/workflows/main_base.yml @@ -31,37 +31,6 @@ jobs: with: toolchain: ${{ inputs.toolchain }} - log-reference: - name: Log index — log-reference.md in sync with source - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - submodules: recursive - - - name: Check log-reference.md is up to date - id: check - run: python3 .mise/scripts/docs/update_log_index.py --check --no-color - - - name: How to fix - if: failure() - run: | - echo "" - echo "════════════════════════════════════════════════════════════" - echo " log-reference.md is out of sync with the source code." - echo "" - echo " Fix it locally by running:" - echo "" - echo " python3 .mise/scripts/docs/update_log_index.py --non-interactive --no-color" - echo "" - echo " Then review the diff, stage, and commit:" - echo "" - echo " git diff documentation/docs/configuration/log-reference.md" - echo " git add documentation/docs/configuration/log-reference.md" - echo " git commit -m 'docs: sync log-reference.md'" - echo "" - echo "════════════════════════════════════════════════════════════" - forward-proxy: uses: ./.github/workflows/forward_proxy.yml with: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1f35ddf6e9..3ee89f3850 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -2,8 +2,9 @@ name: Nightly Packaging # Triggered nightly (schedule), manually, on tag pushes, or on push to -# develop/main. Bot commits from nix-update-hashes.yml carry [skip ci] so -# they do NOT re-trigger this workflow. +# develop/main. Bot commits from nix-update-hashes.yml and +# update-log-index.yml carry [skip ci] so they do NOT re-trigger this +# workflow. on: push: @@ -21,7 +22,8 @@ jobs: # ───────────────────────────────────────────────────────────────────────── # Non-tag refs (branches, schedule, workflow_dispatch): # 1. Recompute Nix vendor hashes on the current branch and commit them. - # 2. Dispatch packaging.yml once both platform jobs have finished. + # 2. Sync log-reference.md with source call-sites and commit it. + # 3. Dispatch packaging.yml once both hash-update platform jobs have finished. # # Packaging is triggered by nix-update-hashes.yml (trigger_packaging=true) # so this workflow does NOT call packaging.yml directly for the branch case. @@ -34,6 +36,17 @@ jobs: trigger_packaging: true secrets: inherit + # ───────────────────────────────────────────────────────────────────────── + # Non-tag refs only: sync log-reference.md with source call-sites and + # commit the result (tag refs are already in sync via release.yml). + # ───────────────────────────────────────────────────────────────────────── + update-log-index: + if: "!startsWith(github.ref, 'refs/tags/')" + uses: ./.github/workflows/update-log-index.yml + with: + ref: ${{ github.ref_name }} + secrets: inherit + # ───────────────────────────────────────────────────────────────────────── # Tag refs only: # Nix hashes are already committed by release.yml — skip the hash update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2898615947..e25f71173c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -236,6 +236,23 @@ jobs: secrets: PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + # ───────────────────────────────────────────────────────────────────────────── + # Job 2b — Sync log-reference.md with source call-sites. + # + # Delegated to the local reusable workflow update-log-index.yml. Sequenced + # after update-nix-hashes so the two do not push to the release branch + # concurrently (both still rebase before pushing, but this avoids the race + # in the common case). + # ───────────────────────────────────────────────────────────────────────────── + update-log-index: + name: Update log index + needs: [prepare, update-nix-hashes] + uses: ./.github/workflows/update-log-index.yml + with: + ref: ${{ needs.prepare.outputs.release_branch }} + secrets: + PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + # ───────────────────────────────────────────────────────────────────────────── # Job 3 — Trigger packaging CI on the release branch and wait for completion # @@ -248,7 +265,7 @@ jobs: # ───────────────────────────────────────────────────────────────────────────── trigger-packaging: name: Trigger & await packaging CI - needs: [prepare, update-nix-hashes] + needs: [prepare, update-nix-hashes, update-log-index] # nix-update-hashes.yml runs a Linux+macOS matrix internally; GitHub waits # for all instances before starting trigger-packaging. runs-on: ubuntu-latest diff --git a/.github/workflows/test_all.yml b/.github/workflows/test_all.yml index a770d42a66..a37fcf604b 100644 --- a/.github/workflows/test_all.yml +++ b/.github/workflows/test_all.yml @@ -23,7 +23,6 @@ jobs: - mariadb - psql - otel - - google-cse - redis - pykmip - wasm @@ -38,9 +37,6 @@ jobs: - iris - db2 - ase - - secret_vault - - secret_aws - - secret_azure - secret_cosmian_kms - spire - kmip-go @@ -86,13 +82,8 @@ jobs: - type: ase features: fips # secret_cosmian_kms runs against a local KMS server — works with both fips and non-fips - # secret_vault, secret_aws, secret_azure require external services — run non-fips only - - type: secret_vault - features: fips - - type: secret_aws - features: fips - - type: secret_azure - features: fips + # google-cse, secret_vault, secret_aws and secret_azure need upstream-only secrets: + # they run in the `test-nix-upstream` job, which is skipped on forks. # spire relies on the Vault API which is non-fips only - type: spire features: fips @@ -209,15 +200,75 @@ jobs: set -ex mise run test:${{ matrix.type }} --variant ${{ matrix.features }} + # Test types that depend on upstream-only secrets / external services. Kept out of + # `test-nix` because a job-level `if` cannot read the `matrix` context: the whole + # job is skipped on forks, where those secrets do not exist. + test-nix-upstream: + name: Test on ${{ matrix.type }} - ${{ matrix.features }} + runs-on: ubuntu-latest + if: github.repository == 'Cosmian/kms' + strategy: + fail-fast: false + matrix: + type: + - google-cse + - secret_vault + - secret_aws + - secret_azure + features: [fips, non-fips] + exclude: + # secret_vault, secret_aws, secret_azure require external services — run non-fips only + - type: secret_vault + features: fips + - type: secret_aws + features: fips + - type: secret_azure + features: fips + + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + + - uses: ./.github/actions/cleanup-runner + + - uses: ./.github/actions/setup-nix + + - uses: ./.github/actions/install-mise + + - name: Test + env: + # Google variables (google-cse test type) + TEST_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.TEST_GOOGLE_OAUTH_CLIENT_ID }} + TEST_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.TEST_GOOGLE_OAUTH_CLIENT_SECRET }} + TEST_GOOGLE_OAUTH_REFRESH_TOKEN: ${{ secrets.TEST_GOOGLE_OAUTH_REFRESH_TOKEN }} + GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY }} + + # AWS secret backend variables (secret_aws test type) + AWS_ACCESS_KEY_ID: ${{ secrets.KMS_CI_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.KMS_CI_AWS_SECRET_ACCESS_KEY }} + AWS_REGION: ${{ secrets.KMS_CI_AWS_REGION }} + + # Azure Key Vault secret backend variables (secret_azure test type) + AZURE_TENANT_ID: ${{ secrets.KMS_CI_AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.KMS_CI_AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.KMS_CI_AZURE_CLIENT_SECRET }} + AZURE_KV_NAME: ${{ secrets.KMS_CI_AZURE_KV_NAME }} + + # Provide an authenticated token so mise can install tools from + # GitHub releases without hitting the unauthenticated rate limit. + GITHUB_TOKEN: ${{ github.token }} + run: | + set -ex + mise run test:${{ matrix.type }} --variant ${{ matrix.features }} + hsm: name: HSM ${{ matrix.hsm-type }} - ${{ matrix.features }} runs-on: ubuntu-latest - # proteccio and crypt2pay hardware do not support concurrent connections from multiple CI runs; - # give them fixed concurrency groups so only one job runs at a time across all PRs. - # utimaco and softhsm2 use a per-run group so they are never blocked by other PRs. + # Software/simulated HSMs: a per-run group so they are never blocked by other PRs. + # Hardware HSMs (proteccio, crypt2pay, aws-cloudhsm) run in the `hsm-upstream` job. concurrency: - group: ${{ (matrix.hsm-type == 'proteccio' && 'hsm-proteccio') || (matrix.hsm-type == 'crypt2pay' && 'hsm-crypt2pay') || format('hsm-{0}-{1}', matrix.hsm-type, - github.run_id) }} + group: ${{ format('hsm-{0}-{1}', matrix.hsm-type, github.run_id) }} queue: max cancel-in-progress: false strategy: @@ -225,17 +276,8 @@ jobs: matrix: hsm-type: - utimaco - - proteccio - softhsm2 - - crypt2pay features: [fips, non-fips] - exclude: - # parallel connections on proteccio is not supported - - hsm-type: proteccio - features: fips - # Not required - testing non-fips is sufficient - - hsm-type: crypt2pay - features: fips steps: - uses: actions/checkout@v7 @@ -250,18 +292,74 @@ jobs: - name: Test env: - # HSM - PROTECCIO_IP: ${{ secrets.PROTECCIO_IP }} - PROTECCIO_PASSWORD: ${{ secrets.PROTECCIO_PASSWORD }} - PROTECCIO_SLOT: ${{ secrets.PROTECCIO_SLOT }} - CRYPT2PAY_PASSWORD: ${{ secrets.CRYPT2PAY_PASSWORD }} # Google variables TEST_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.TEST_GOOGLE_OAUTH_CLIENT_ID }} TEST_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.TEST_GOOGLE_OAUTH_CLIENT_SECRET }} TEST_GOOGLE_OAUTH_REFRESH_TOKEN: ${{ secrets.TEST_GOOGLE_OAUTH_REFRESH_TOKEN }} GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY }} - # OVPN for Crypt2Pay HSM tests + run: | + mise run test:hsm-${{ matrix.hsm-type }} --variant ${{ matrix.features }} + + # Hardware HSMs reachable only with upstream-only secrets and infrastructure (Proteccio + # network HSM, Crypt2Pay over OpenVPN, the AWS CloudHSM CI cluster). They live in their + # own job (a job-level `if` cannot read the `matrix` context) and are skipped on forks. + hsm-upstream: + name: HSM ${{ matrix.hsm-type }} - ${{ matrix.features }} + runs-on: ubuntu-latest + if: github.repository == 'Cosmian/kms' + # This hardware does not support concurrent connections from multiple CI runs: a fixed + # group per HSM so only one job runs at a time across all PRs. + concurrency: + group: ${{ format('hsm-{0}', matrix.hsm-type) }} + queue: max + cancel-in-progress: false + strategy: + fail-fast: false + matrix: + hsm-type: + - proteccio + - crypt2pay + - aws-cloudhsm + # non-fips only: parallel connections on proteccio are not supported, non-fips is + # sufficient for crypt2pay, and aws-cloudhsm FIPS-mode compatibility is not yet + # confirmed against the cluster's supported mechanism list. + features: [non-fips] + + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + + - uses: ./.github/actions/cleanup-runner + + - uses: ./.github/actions/setup-nix + + - uses: ./.github/actions/install-mise + + - name: Test + env: + # Proteccio + PROTECCIO_IP: ${{ secrets.PROTECCIO_IP }} + PROTECCIO_PASSWORD: ${{ secrets.PROTECCIO_PASSWORD }} + PROTECCIO_SLOT: ${{ secrets.PROTECCIO_SLOT }} + # Crypt2Pay (reached through OpenVPN) + CRYPT2PAY_PASSWORD: ${{ secrets.CRYPT2PAY_PASSWORD }} OVPN_CONF: ${{ secrets.OVPN_CONF }} + # AWS CloudHSM: persistent CI cluster (see crate/hsm/aws_cloudhsm/README.md) + AWS_CLOUDHSM_CLUSTER_ID: ${{ secrets.KMS_CI_AWS_CLOUDHSM_CLUSTER_ID }} + AWS_CLOUDHSM_CU_USERNAME: ${{ secrets.KMS_CI_AWS_CLOUDHSM_CU_USERNAME }} + AWS_CLOUDHSM_CU_PASSWORD: ${{ secrets.KMS_CI_AWS_CLOUDHSM_CU_PASSWORD }} + AWS_CLOUDHSM_SLOT_ID: ${{ secrets.KMS_CI_AWS_CLOUDHSM_SLOT_ID }} + AWS_CLOUDHSM_CA_CERT: ${{ secrets.KMS_CI_AWS_CLOUDHSM_CA_CERT }} + AWS_CLOUDHSM_HSM_IPS: ${{ secrets.KMS_CI_AWS_CLOUDHSM_HSM_IPS }} + # AWS Client VPN profile (.ovpn, cert-based mutual auth): GitHub-hosted + # runners have no route to the HSM's private VPC IP; prepare_aws_cloudhsm.sh + # starts this tunnel only if the HSM is not already directly reachable. + AWS_CLOUDHSM_OVPN_CONF: ${{ secrets.KMS_CI_AWS_CLOUDHSM_OVPN_CONF }} + # Reused generic AWS credentials (read-only `cloudhsm:DescribeClusters` is enough) + AWS_ACCESS_KEY_ID: ${{ secrets.KMS_CI_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.KMS_CI_AWS_SECRET_ACCESS_KEY }} + AWS_REGION: ${{ secrets.KMS_CI_AWS_REGION }} run: | mise run test:hsm-${{ matrix.hsm-type }} --variant ${{ matrix.features }} @@ -302,7 +400,9 @@ jobs: permissions: contents: read environment: xks-remote-approval + # Upstream only: needs the XKS test server and its secrets, which forks do not have. if: >- + github.repository == 'Cosmian/kms' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) @@ -361,8 +461,13 @@ jobs: cargo-publish: needs: - test-nix + - test-nix-upstream - hsm + - hsm-upstream - helm + # The upstream-only jobs are skipped on forks: a skipped dependency must not skip the + # publish dry-run, but any failed or cancelled dependency still blocks it. + if: ${{ !failure() && !cancelled() }} uses: ./.github/workflows/cargo-publish.yml with: toolchain: 1.97.0 diff --git a/.github/workflows/update-log-index.yml b/.github/workflows/update-log-index.yml new file mode 100644 index 0000000000..61f1aa4bef --- /dev/null +++ b/.github/workflows/update-log-index.yml @@ -0,0 +1,81 @@ +--- +# Local reusable workflow — Update log-reference.md. +# +# Runs `mise run docs:log-index` (non-interactive, deletes stale entries) and +# commits the result back to `ref`. Mirrors nix-update-hashes.yml's +# checkout → run → commit-if-changed → rebase → push flow. +# +# Callers / triggers +# ────────────────── +# nightly.yml — non-tag refs only (branches/schedule/workflow_dispatch); tag +# refs are already in sync via release.yml. +# release.yml — Job: runs on the release branch after `prepare` (and after +# `update-nix-hashes`, to avoid two independent workflows +# pushing to the same release branch at once). + +name: Update log index + +on: + workflow_dispatch: + inputs: + ref: + description: > + Branch to update (leave empty to use the dispatched branch). + required: false + type: string + default: '' + + workflow_call: + inputs: + ref: + description: > + Git ref (branch name) to check out and push the updated + log-reference.md to. + required: true + type: string + secrets: + PAT_TOKEN: + description: > + Personal Access Token with `repo` + `workflow` scopes. + Required so that the push re-triggers other workflows + (GITHUB_TOKEN cannot do this). + required: true + +jobs: + update-log-index: + name: Update log index + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.ref_name }} + fetch-depth: 0 + submodules: recursive + token: ${{ secrets.PAT_TOKEN }} + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - uses: ./.github/actions/install-mise + + - name: Update log-reference.md + run: mise run docs:log-index + + - name: Commit updated log index + run: | + REF="${{ inputs.ref || github.ref_name }}" + git add documentation/docs/configuration/log-reference.md + if git diff --cached --quiet; then + echo "log-reference.md is already up-to-date, nothing to commit." + else + # [skip ci] prevents push-triggered workflows (e.g. nightly.yml) + # from re-running on this automated commit and creating a loop. + git commit -m "docs: sync log-reference.md [skip ci]" + # Pull after committing to avoid "index contains uncommitted + # changes" errors if another job pushed to the same ref meanwhile. + git pull --rebase origin "$REF" + git push origin "$REF" + fi From 6cedb45fcf52fb3902eca9f0d7090eb3af1c2fcd Mon Sep 17 00:00:00 2001 From: Manuthor Date: Wed, 30 Sep 2026 08:54:04 +0200 Subject: [PATCH 14/14] fix: cargo fmt --- crate/server/src/middlewares/jwt/jwt_token_auth.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crate/server/src/middlewares/jwt/jwt_token_auth.rs b/crate/server/src/middlewares/jwt/jwt_token_auth.rs index 629e102fb6..a9c00df127 100644 --- a/crate/server/src/middlewares/jwt/jwt_token_auth.rs +++ b/crate/server/src/middlewares/jwt/jwt_token_auth.rs @@ -120,10 +120,7 @@ fn resolve_authenticated_user( auth_method: AuthMethod::OidcJwt, }); } - let has_spiffe_subject = user_claim - .sub - .as_deref() - .is_some_and(is_spiffe_id); + let has_spiffe_subject = user_claim.sub.as_deref().is_some_and(is_spiffe_id); if accept_spiffe_subject && has_spiffe_subject { // SPIFFE JWT-SVID: no email claim, but a validated spiffe:// subject and the // issuer's config explicitly opted in via `--jwt-svid-auth`.