diff --git a/Cargo.lock b/Cargo.lock index 31a2c40..7692a1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2953,7 +2953,7 @@ dependencies = [ [[package]] name = "jwtlet-core" -version = "0.0.1" +version = "0.0.2" dependencies = [ "async-trait", "bon", @@ -2972,7 +2972,7 @@ dependencies = [ [[package]] name = "jwtlet-e2e-tests" -version = "0.0.1" +version = "0.0.2" dependencies = [ "anyhow", "dsdk-facet-core 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", @@ -2987,7 +2987,7 @@ dependencies = [ [[package]] name = "jwtlet-postgres" -version = "0.0.1" +version = "0.0.2" dependencies = [ "async-trait", "jwtlet-core", @@ -3000,7 +3000,7 @@ dependencies = [ [[package]] name = "jwtlet-server" -version = "0.0.1" +version = "0.0.2" dependencies = [ "anyhow", "async-trait", @@ -3032,6 +3032,7 @@ dependencies = [ "tracing-opentelemetry", "tracing-subscriber", "tracing-test", + "uuid", ] [[package]] diff --git a/README.md b/README.md index e2b40f2..4824a25 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ token_file = "/var/run/secrets/kubernetes.io/serviceaccount/token" # Token issuance [token] -client_audience = "https://kubernetes.default.svc.cluster.local" # required – expected aud of incoming tokens +client_audience = "jwtlet-exchange" # required – expected aud of incoming tokens; use a dedicated value, never the API server audience audience = "my-service" # required – aud of issued tokens participant_context_claim = "jwtlet_pc" # default token_ttl_secs = 3600 # default @@ -95,26 +95,32 @@ url = "http://vault:8200" # required token_file = "/vault/secrets/.vault-token" # use token_file in production # token = "s.xxxxx" # or a literal token for development +# Management API +[management] +client_audience = "jwtlet-management" # optional – expected aud of management bearer tokens; defaults to token.client_audience (a distinct value is recommended) + # Management API authorization -# Keys are Kubernetes service account identifiers; values are lists of roles. -# A caller must hold the "management:write" role to use any management endpoint. +# Keys are Kubernetes service account identifiers; values are lists of roles: +# jwtlet:management:read – GET /api/v1/mappings, GET /api/v1/scopes +# jwtlet:management:mappings:write – create/update/delete resource mappings +# jwtlet:management:scope:write – create/update/delete scope mappings [service_accounts] -"system:serviceaccount:my-namespace:my-sa" = ["management:write"] +"system:serviceaccount:my-namespace:my-sa" = ["jwtlet:management:mappings:write", "jwtlet:management:scope:write"] ``` ### Management API Authentication All management API endpoints (`/api/v1/mappings`, `/api/v1/scopes`) require a -`Authorization: Bearer ` header. The token must be a valid Kubernetes service -account token issued with the same audience as `token.client_audience`. Jwtlet -verifies the token via the Kubernetes TokenReview API and checks that the resolved -service account identity holds the `management:write` role. +`Authorization: Bearer ` header. The token must be a Kubernetes service +account token issued for `management.client_audience` (or `token.client_audience` when it +is not set). Jwtlet verifies the token via +the Kubernetes TokenReview API (including the returned audience) and checks that the +resolved service account identity holds the role required by the endpoint. To obtain a suitable token from within a cluster: ```bash -kubectl create token my-sa -n my-namespace \ - --audience=https://kubernetes.default.svc.cluster.local +kubectl create token my-sa -n my-namespace --audience=jwtlet-management ``` ### Logging diff --git a/crates/jwtlet-core/src/k8s/mod.rs b/crates/jwtlet-core/src/k8s/mod.rs index 9f52845..27a205d 100644 --- a/crates/jwtlet-core/src/k8s/mod.rs +++ b/crates/jwtlet-core/src/k8s/mod.rs @@ -28,6 +28,11 @@ use std::sync::Arc; use tokio::sync::RwLock; const TOKEN_REVIEW_API: &str = "/apis/authentication.k8s.io/v1/tokenreviews"; +const SERVICE_ACCOUNT_USERNAME_PREFIX: &str = "system:serviceaccount:"; +const SERVICE_ACCOUNTS_GROUP: &str = "system:serviceaccounts"; + +/// Custom claim carrying the authenticated identity's K8s UID, when the TokenReview returns one. +pub const K8S_UID_CLAIM: &str = "k8s_uid"; /// Verifies a Kubernetes Service Account JWT using the K8S TokenReview API. /// @@ -53,6 +58,12 @@ pub struct K8sTokenReviewVerifier { #[builder(default = Client::new())] client: Client, + /// When `true` (the default), only Kubernetes service account identities are accepted. + /// Identities from other API server authenticators (OIDC users, static or bootstrap tokens) + /// are rejected even when TokenReview reports them as authenticated. + #[builder(default = true)] + require_service_account: bool, + #[builder(skip)] state: Option>>, @@ -184,10 +195,30 @@ impl JwtVerifier for K8sTokenReviewVerifier { return Err(VerificationFailed(msg)); } + // Authenticators that are not audience-aware may authenticate a token without honoring the + // requested audiences; the TokenReview contract requires clients to check the intersection. + if !review.status.audiences.iter().any(|a| a == audience) { + return Err(VerificationFailed(format!( + "TokenReview did not confirm the requested audience '{audience}'" + ))); + } + let user = review.status.user.ok_or_else(|| { VerificationFailed("TokenReview returned authenticated=true but no user info".to_string()) })?; + if self.require_service_account && !user.is_service_account() { + return Err(VerificationFailed(format!( + "identity '{}' is not a Kubernetes service account", + user.username + ))); + } + + let mut custom = Map::new(); + if let Some(uid) = user.uid.filter(|u| !u.is_empty()) { + custom.insert(K8S_UID_CLAIM.to_string(), uid.into()); + } + Ok(TokenClaims { sub: user.username, iss: self.cluster_issuer.clone(), @@ -195,7 +226,7 @@ impl JwtVerifier for K8sTokenReviewVerifier { iat: 0, exp: 0, nbf: None, - custom: Map::new(), + custom, }) } } @@ -226,9 +257,22 @@ struct TokenReviewStatus { user: Option, #[serde(default)] error: Option, + #[serde(default)] + audiences: Vec, } #[derive(Deserialize)] struct UserInfo { username: String, + #[serde(default)] + uid: Option, + #[serde(default)] + groups: Vec, +} + +impl UserInfo { + fn is_service_account(&self) -> bool { + self.username.starts_with(SERVICE_ACCOUNT_USERNAME_PREFIX) + && self.groups.iter().any(|g| g == SERVICE_ACCOUNTS_GROUP) + } } diff --git a/crates/jwtlet-core/src/k8s/tests/mod.rs b/crates/jwtlet-core/src/k8s/tests/mod.rs index d7fda98..8114b60 100644 --- a/crates/jwtlet-core/src/k8s/tests/mod.rs +++ b/crates/jwtlet-core/src/k8s/tests/mod.rs @@ -14,7 +14,7 @@ pub mod renewal; -use crate::k8s::K8sTokenReviewVerifier; +use crate::k8s::{K8S_UID_CLAIM, K8sTokenReviewVerifier}; use dsdk_facet_core::jwt::{JwtVerificationError, JwtVerifier}; use serde_json::json; use std::path::PathBuf; @@ -52,7 +52,12 @@ async fn verify_token_returns_claims_for_authenticated_token() { .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "status": { "authenticated": true, - "user": { "username": "system:serviceaccount:default:test-sa" } + "audiences": [AUDIENCE], + "user": { + "username": "system:serviceaccount:default:test-sa", + "uid": "7a1c7e65-b871-4d3a-9f0e-000000000001", + "groups": ["system:serviceaccounts", "system:serviceaccounts:default", "system:authenticated"] + } } }))) .mount(&server) @@ -64,6 +69,7 @@ async fn verify_token_returns_claims_for_authenticated_token() { assert_eq!(claims.sub, "system:serviceaccount:default:test-sa"); assert_eq!(claims.iss, CLUSTER_ISSUER); assert_eq!(claims.aud, AUDIENCE); + assert_eq!(claims.custom[K8S_UID_CLAIM], "7a1c7e65-b871-4d3a-9f0e-000000000001"); } #[tokio::test] @@ -124,7 +130,11 @@ async fn verify_token_retries_on_401_with_refreshed_sa_token() { .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "status": { "authenticated": true, - "user": { "username": "system:serviceaccount:ns:sa" } + "audiences": [AUDIENCE], + "user": { + "username": "system:serviceaccount:ns:sa", + "groups": ["system:serviceaccounts", "system:serviceaccounts:ns"] + } } }))) .mount(&server) @@ -165,3 +175,128 @@ async fn verify_token_fails_before_initialize() { }; assert!(msg.contains("not initialized"), "unexpected message: {msg}"); } + +async fn mount_review(server: &MockServer, status: serde_json::Value) { + Mock::given(method("POST")) + .and(path(TOKEN_REVIEW_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "status": status }))) + .mount(server) + .await; +} + +async fn expect_verification_failure(v: &K8sTokenReviewVerifier) -> String { + let Err(JwtVerificationError::VerificationFailed(msg)) = v.verify_token(AUDIENCE, SUBJECT_TOKEN).await else { + panic!("expected VerificationFailed"); + }; + msg +} + +#[tokio::test] +async fn verify_token_fails_when_review_omits_audiences() { + let server = MockServer::start().await; + let token_file = write_sa_token_file("sa-token"); + mount_review( + &server, + json!({ + "authenticated": true, + "user": { "username": "system:serviceaccount:ns:sa", "groups": ["system:serviceaccounts"] } + }), + ) + .await; + + let v = make_verifier(&server, &token_file).await; + let msg = expect_verification_failure(&v).await; + assert!(msg.contains("audience"), "unexpected message: {msg}"); +} + +#[tokio::test] +async fn verify_token_fails_when_review_returns_other_audience() { + let server = MockServer::start().await; + let token_file = write_sa_token_file("sa-token"); + // An audience-unaware authenticator answers with the API server's own audiences. + mount_review( + &server, + json!({ + "authenticated": true, + "audiences": ["https://kubernetes.default.svc.cluster.local"], + "user": { "username": "system:serviceaccount:ns:sa", "groups": ["system:serviceaccounts"] } + }), + ) + .await; + + let v = make_verifier(&server, &token_file).await; + let msg = expect_verification_failure(&v).await; + assert!(msg.contains("audience"), "unexpected message: {msg}"); +} + +#[tokio::test] +async fn verify_token_rejects_non_service_account_identity() { + let server = MockServer::start().await; + let token_file = write_sa_token_file("sa-token"); + mount_review( + &server, + json!({ + "authenticated": true, + "audiences": [AUDIENCE], + "user": { "username": "oidc:alice@example.com", "groups": ["system:authenticated"] } + }), + ) + .await; + + let v = make_verifier(&server, &token_file).await; + let msg = expect_verification_failure(&v).await; + assert!( + msg.contains("not a Kubernetes service account"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +async fn verify_token_rejects_service_account_username_without_group() { + let server = MockServer::start().await; + let token_file = write_sa_token_file("sa-token"); + // A username that merely looks like a SA (e.g. from an unprefixed OIDC claim) is not enough. + mount_review( + &server, + json!({ + "authenticated": true, + "audiences": [AUDIENCE], + "user": { "username": "system:serviceaccount:ns:sa", "groups": ["system:authenticated"] } + }), + ) + .await; + + let v = make_verifier(&server, &token_file).await; + let msg = expect_verification_failure(&v).await; + assert!( + msg.contains("not a Kubernetes service account"), + "unexpected message: {msg}" + ); +} + +#[tokio::test] +async fn verify_token_accepts_non_service_account_when_not_required() { + let server = MockServer::start().await; + let token_file = write_sa_token_file("sa-token"); + mount_review( + &server, + json!({ + "authenticated": true, + "audiences": [AUDIENCE], + "user": { "username": "oidc:alice@example.com" } + }), + ) + .await; + + let mut v = K8sTokenReviewVerifier::builder() + .api_server_url(server.uri()) + .cluster_issuer(CLUSTER_ISSUER) + .token_file(token_file.clone()) + .require_service_account(false) + .build(); + v.initialize().await.unwrap(); + + let claims = v.verify_token(AUDIENCE, SUBJECT_TOKEN).await.unwrap(); + assert_eq!(claims.sub, "oidc:alice@example.com"); + assert!(!claims.custom.contains_key(K8S_UID_CLAIM)); +} diff --git a/crates/jwtlet-core/src/resource/mem.rs b/crates/jwtlet-core/src/resource/mem.rs index 5d79f8b..a5c700c 100644 --- a/crates/jwtlet-core/src/resource/mem.rs +++ b/crates/jwtlet-core/src/resource/mem.rs @@ -12,7 +12,7 @@ use super::{MappingPair, ResourceError, ResourceMapping, ResourceStore, ScopeMapping}; use async_trait::async_trait; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tokio::sync::RwLock; struct InnerStore { @@ -105,6 +105,12 @@ impl ResourceStore for MemoryResourceStore { async fn save_scope_mappings(&self, mappings: Vec) -> Result<(), ResourceError> { // Holding the write lock for the whole batch makes the insert atomic. let mut store = self.store.write().await; + let mut seen = HashSet::new(); + for mapping in &mappings { + if store.scope_mappings.contains_key(&mapping.scope) || !seen.insert(mapping.scope.as_str()) { + return Err(ResourceError::Conflict(mapping.scope.clone())); + } + } for mapping in mappings { store.scope_mappings.insert(mapping.scope.clone(), mapping); } diff --git a/crates/jwtlet-core/src/resource/mod.rs b/crates/jwtlet-core/src/resource/mod.rs index 42af677..cde04a8 100644 --- a/crates/jwtlet-core/src/resource/mod.rs +++ b/crates/jwtlet-core/src/resource/mod.rs @@ -18,7 +18,7 @@ use async_trait::async_trait; use bon::Builder; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::Arc; use thiserror::Error; @@ -36,6 +36,7 @@ pub trait ResourceStore: Send + Sync { async fn remove_mappings_for(&self, client_identifier: &str) -> Result<(), ResourceError>; /// Persists all `mappings` atomically: either every mapping is saved or none is. + /// Fails with [`ResourceError::Conflict`] if any scope already exists (or repeats in the batch). async fn save_scope_mappings(&self, mappings: Vec) -> Result<(), ResourceError>; async fn update_scope_mapping(&self, mapping: ScopeMapping) -> Result<(), ResourceError>; async fn remove_scope_mapping(&self, scope: &str) -> Result<(), ResourceError>; @@ -61,13 +62,59 @@ pub enum ResourceError { #[error("Reserved JWT claim key used in scope mapping: {0}")] ReservedClaim(String), + + #[error("Invalid mapping: {0}")] + InvalidMapping(String), +} + +/// Claim keys that jwtlet sets authoritatively and that scope mappings may therefore not set. +pub const RESERVED_CLAIMS: &[&str] = &["sub", "iss", "aud", "exp", "iat", "nbf", "act", "jti"]; + +pub fn is_reserved_claim(key: &str) -> bool { + RESERVED_CLAIMS.contains(&key) +} + +/// Scopes are requested as a whitespace-separated list, so a scope name containing whitespace +/// (or an empty one) could never be requested and only makes the policy harder to read. +fn validate_scope_name(scope: &str) -> Result<(), ResourceError> { + if scope.is_empty() || scope.chars().any(char::is_whitespace) { + return Err(ResourceError::InvalidMapping(format!( + "scope '{scope}' must be non-empty and contain no whitespace" + ))); + } + Ok(()) } -const RESERVED_CLAIMS: &[&str] = &["sub", "iss", "aud", "exp", "iat", "nbf", "act", "jti"]; +fn validate_resource_mapping(mapping: &ResourceMapping) -> Result<(), ResourceError> { + if mapping.client_identifier.trim().is_empty() { + return Err(ResourceError::InvalidMapping( + "clientIdentifier must not be empty".into(), + )); + } + if mapping.participant_context.trim().is_empty() { + return Err(ResourceError::InvalidMapping( + "participantContext must not be empty".into(), + )); + } + for scope in &mapping.scopes { + validate_scope_name(scope)?; + } + if mapping.audiences.iter().any(|a| a.trim().is_empty()) { + return Err(ResourceError::InvalidMapping( + "audiences must not contain empty values".into(), + )); + } + Ok(()) +} + +fn validate_scope_mapping(mapping: &ScopeMapping) -> Result<(), ResourceError> { + validate_scope_name(&mapping.scope)?; + validate_scope_claims(&mapping.claims) +} fn validate_scope_claims(claims: &Map) -> Result<(), ResourceError> { for key in claims.keys() { - if RESERVED_CLAIMS.contains(&key.as_str()) { + if is_reserved_claim(key) { return Err(ResourceError::ReservedClaim(key.clone())); } } @@ -120,6 +167,8 @@ impl ResourceService { participant_context: &str, scopes: Vec, ) -> Result { + // A scope requested twice must expand only once; ordering keeps claim merging deterministic. + let scopes: BTreeSet = scopes.into_iter().collect(); let Some(pair) = self .store .resolve_mapping(client_identifier, participant_context) @@ -173,10 +222,12 @@ impl ResourceService { } pub async fn save(&self, mapping: ResourceMapping) -> Result<(), ResourceError> { + validate_resource_mapping(&mapping)?; self.store.save_mapping(mapping).await } pub async fn update(&self, mapping: ResourceMapping) -> Result<(), ResourceError> { + validate_resource_mapping(&mapping)?; self.store.update_mapping(mapping).await } @@ -189,16 +240,16 @@ impl ResourceService { } /// Validates and persists all `mappings` atomically. If any mapping fails - /// validation, none is persisted. + /// validation, or any scope already exists, none is persisted. pub async fn save_scope_mappings(&self, mappings: Vec) -> Result<(), ResourceError> { for mapping in &mappings { - validate_scope_claims(&mapping.claims)?; + validate_scope_mapping(mapping)?; } self.store.save_scope_mappings(mappings).await } pub async fn update_scope_mapping(&self, mapping: ScopeMapping) -> Result<(), ResourceError> { - validate_scope_claims(&mapping.claims)?; + validate_scope_mapping(&mapping)?; self.store.update_scope_mapping(mapping).await } diff --git a/crates/jwtlet-core/src/resource/tests/mem.rs b/crates/jwtlet-core/src/resource/tests/mem.rs index c379340..8cd0457 100644 --- a/crates/jwtlet-core/src/resource/tests/mem.rs +++ b/crates/jwtlet-core/src/resource/tests/mem.rs @@ -218,3 +218,28 @@ async fn save_mapping_allows_same_client_in_different_contexts() { assert!(store.resolve_mapping("client1", "ctx1").await.unwrap().is_some()); assert!(store.resolve_mapping("client1", "ctx2").await.unwrap().is_some()); } + +#[tokio::test] +async fn save_scope_mappings_returns_conflict_for_existing_scope() { + let store = MemoryResourceStore::new(); + store.save_scope_mappings(vec![scope_mapping("read")]).await.unwrap(); + + let result = store + .save_scope_mappings(vec![scope_mapping("write"), scope_mapping("read")]) + .await; + assert!(matches!(result, Err(ResourceError::Conflict(ref s)) if s == "read")); + + // The batch is atomic: "write" must not have been persisted. + let scopes = store.list_scope_mappings().await.unwrap(); + assert_eq!(scopes.len(), 1); +} + +#[tokio::test] +async fn save_scope_mappings_returns_conflict_for_duplicate_in_batch() { + let store = MemoryResourceStore::new(); + let result = store + .save_scope_mappings(vec![scope_mapping("read"), scope_mapping("read")]) + .await; + assert!(matches!(result, Err(ResourceError::Conflict(_)))); + assert!(store.list_scope_mappings().await.unwrap().is_empty()); +} diff --git a/crates/jwtlet-core/src/resource/tests/resource_service.rs b/crates/jwtlet-core/src/resource/tests/resource_service.rs index 9568939..58b37f5 100644 --- a/crates/jwtlet-core/src/resource/tests/resource_service.rs +++ b/crates/jwtlet-core/src/resource/tests/resource_service.rs @@ -324,6 +324,101 @@ async fn update_scope_mapping_rejects_reserved_claims() { assert!(matches!(result, Err(ResourceError::ReservedClaim(_)))); } +#[tokio::test] +async fn save_rejects_invalid_resource_mappings() { + let service = create_service(); + let with_audience = |aud: &str| { + let mut m = create_mapping("client", "ctx", &["read"]); + m.audiences = HashSet::from([aud.to_string()]); + m + }; + let invalid = [ + create_mapping("", "ctx", &["read"]), + create_mapping(" ", "ctx", &["read"]), + create_mapping("client", "", &["read"]), + create_mapping("client", "ctx", &[""]), + create_mapping("client", "ctx", &["read write"]), + with_audience(""), + ]; + for mapping in invalid { + let result = service.save(mapping.clone()).await; + assert!( + matches!(result, Err(ResourceError::InvalidMapping(_))), + "expected InvalidMapping for {mapping:?}" + ); + } + assert!(service.list_mappings().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn update_rejects_invalid_resource_mapping() { + let service = create_service(); + service.save(create_mapping("client", "ctx", &["read"])).await.unwrap(); + + let result = service.update(create_mapping("client", "ctx", &["read write"])).await; + assert!(matches!(result, Err(ResourceError::InvalidMapping(_)))); +} + +#[tokio::test] +async fn save_scope_mappings_rejects_invalid_scope_names() { + let service = create_service(); + for scope in ["", "read write", "read\t"] { + let result = service + .save_scope_mappings(vec![ + ScopeMapping::builder() + .scope(scope.to_string()) + .claims(Map::new()) + .build(), + ]) + .await; + assert!( + matches!(result, Err(ResourceError::InvalidMapping(_))), + "expected InvalidMapping for scope {scope:?}" + ); + } +} + +#[tokio::test] +async fn save_scope_mappings_returns_conflict_for_existing_scope() { + let service = create_service(); + let mapping = |role: &str| { + let mut claims = Map::new(); + claims.insert("role".to_string(), Value::String(role.to_string())); + ScopeMapping::builder().scope("read".to_string()).claims(claims).build() + }; + service.save_scope_mappings(vec![mapping("reader")]).await.unwrap(); + + let result = service.save_scope_mappings(vec![mapping("admin")]).await; + assert!(matches!(result, Err(ResourceError::Conflict(_)))); + assert_eq!( + service.list_scope_mappings().await.unwrap()[0].claims["role"], + Value::String("reader".to_string()) + ); +} + +#[tokio::test] +async fn verify_expands_duplicate_requested_scope_once() { + let service = create_service(); + service.save(create_mapping("client", "ctx", &["read"])).await.unwrap(); + let mut claims = Map::new(); + claims.insert("perm".to_string(), Value::String("read".to_string())); + claims.insert("level".to_string(), Value::from(1)); + service + .save_scope_mappings(vec![ + ScopeMapping::builder().scope("read".to_string()).claims(claims).build(), + ]) + .await + .unwrap(); + + let result = service + .verify("client", "ctx", vec!["read".to_string(), "read".to_string()]) + .await + .unwrap(); + assert!(result.verified); + assert_eq!(result.claims["perm"], Value::String("read".to_string())); + assert_eq!(result.claims["level"], Value::from(1)); +} + fn create_service() -> ResourceService { ResourceService::builder() .store(Arc::new(MemoryResourceStore::new())) diff --git a/crates/jwtlet-core/src/token/mod.rs b/crates/jwtlet-core/src/token/mod.rs index f4d7870..246b61a 100644 --- a/crates/jwtlet-core/src/token/mod.rs +++ b/crates/jwtlet-core/src/token/mod.rs @@ -13,7 +13,8 @@ #[cfg(test)] mod tests; -use crate::resource::{ResourceError, ResourceService}; +use crate::k8s::K8S_UID_CLAIM; +use crate::resource::{ResourceError, ResourceService, is_reserved_claim}; use bon::Builder; use chrono::Utc; use dsdk_facet_core::context::ParticipantContext; @@ -21,6 +22,11 @@ use dsdk_facet_core::jwt::{JwtGenerationError, JwtGenerator, JwtVerificationErro use serde_json::json; use std::collections::HashSet; use thiserror::Error; +use tracing::warn; +use uuid::Uuid; + +/// Upper bound for `token_ttl_secs`. +pub const MAX_TOKEN_TTL_SECS: i64 = 86_400; #[derive(Builder)] pub struct TokenExchangeService { @@ -70,15 +76,28 @@ impl TokenExchangeService { let aud = resolve_audience(audience, &verification.audiences, &self.audience)?; + // Reserved keys are rejected when scope mappings are written, but the store may have been + // populated by other means. Filter again here: `custom` is flattened into the payload, so + // a leaked `sub` or `aud` would produce a duplicate key that most parsers resolve to the + // attacker-controlled value. let mut custom = serde_json::Map::new(); - custom.extend(verification.claims.into_iter().map(|(k, v)| (k, v))); - custom.insert( - "act".to_string(), - json!({ - "sub": client_claims.sub, - "iss": client_claims.iss, - }), - ); + for (k, v) in verification.claims { + if is_reserved_claim(&k) { + warn!(claim = %k, participant_context, "dropping reserved claim from scope mapping"); + continue; + } + custom.insert(k, v); + } + + let mut act = json!({ + "sub": client_claims.sub, + "iss": client_claims.iss, + }); + if let Some(uid) = client_claims.custom.get(K8S_UID_CLAIM) { + act[K8S_UID_CLAIM] = uid.clone(); + } + custom.insert("act".to_string(), act); + custom.insert("jti".to_string(), Uuid::new_v4().to_string().into()); let now = Utc::now().timestamp(); let participant_claims = TokenClaims::builder() diff --git a/crates/jwtlet-core/src/token/tests/mod.rs b/crates/jwtlet-core/src/token/tests/mod.rs index d7a2910..c5310e6 100644 --- a/crates/jwtlet-core/src/token/tests/mod.rs +++ b/crates/jwtlet-core/src/token/tests/mod.rs @@ -406,6 +406,122 @@ async fn exchange_token_unauthorized_audience_does_not_leak_token() { assert!(matches!(result, Err(ExchangeError::Unauthorized))); } +// ============================================================================ +// Claim hardening +// ============================================================================ + +fn scope_mapping(scope: &str, claims: serde_json::Value) -> (String, ScopeMapping) { + let Value::Object(claims) = claims else { + panic!("claims must be an object") + }; + ( + scope.to_string(), + ScopeMapping::builder().scope(scope.to_string()).claims(claims).build(), + ) +} + +async fn exchange_capturing(store: StubStore, scopes: &[&str]) -> TokenClaims { + let sink = Arc::new(Mutex::new(None)); + make_service(ok_verifier(), capturing_generator(Arc::clone(&sink)), store) + .exchange_token( + PARTICIPANT_CONTEXT, + scopes.iter().map(|s| s.to_string()).collect(), + "input-token", + None, + ) + .await + .unwrap(); + sink.lock().unwrap().take().unwrap() +} + +#[tokio::test] +async fn exchange_token_drops_reserved_claims_stored_outside_the_service() { + // Simulates rows written directly to the store, bypassing write-time validation. + let scope_mappings = HashMap::from([scope_mapping( + "read", + serde_json::json!({ + "sub": "other-context", + "aud": "https://evil.example.com", + "act": {"sub": "forged"}, + "jti": "fixed", + "scope": "api:read", + "role": "reader" + }), + )]); + + let claims = exchange_capturing(mapping_store_with_scopes(mapping(&["read"]), scope_mappings), &["read"]).await; + + assert_eq!(claims.sub, PARTICIPANT_CONTEXT); + assert_eq!(claims.aud, TOKEN_AUDIENCE); + assert!(!claims.custom.contains_key("sub")); + assert!(!claims.custom.contains_key("aud")); + assert_eq!(claims.custom["scope"], "api:read"); + assert_eq!(claims.custom["act"]["sub"], CLIENT_SUB); + assert_ne!(claims.custom["jti"], "fixed"); + assert_eq!(claims.custom["role"], "reader"); + + // The serialized payload must not carry duplicate registered claims. + let payload = serde_json::to_string(&claims).unwrap(); + assert_eq!( + payload.matches("\"sub\":").count(), + 2, + "top-level sub + act.sub only: {payload}" + ); + assert_eq!(payload.matches("\"aud\":").count(), 1, "{payload}"); +} + +#[tokio::test] +async fn exchange_token_issues_unique_jti() { + let first = exchange_capturing(mapping_store(mapping(&["read"])), &["read"]).await; + let second = exchange_capturing(mapping_store(mapping(&["read"])), &["read"]).await; + + let first_jti = first.custom["jti"].as_str().unwrap(); + let second_jti = second.custom["jti"].as_str().unwrap(); + assert!(uuid::Uuid::parse_str(first_jti).is_ok()); + assert_ne!(first_jti, second_jti); +} + +#[tokio::test] +async fn exchange_token_expands_duplicate_scopes_once() { + let scope_mappings = HashMap::from([ + scope_mapping("read", serde_json::json!({"perm": "read", "level": 1})), + scope_mapping("write", serde_json::json!({"perm": "write"})), + ]); + + let claims = exchange_capturing( + mapping_store_with_scopes(mapping(&["read", "write"]), scope_mappings), + &["write", "read", "read"], + ) + .await; + + // Non-string claims would conflict and string claims would repeat if "read" expanded twice. + assert_eq!(claims.custom["level"], 1); + assert_eq!(claims.custom["perm"], "read write"); +} + +#[tokio::test] +async fn exchange_token_records_k8s_uid_in_actor_claim() { + let verifier = StubVerifier(Box::new(|| { + let mut claims = client_claims(); + claims + .custom + .insert(crate::k8s::K8S_UID_CLAIM.to_string(), "uid-123".into()); + Ok(claims) + })); + let sink = Arc::new(Mutex::new(None)); + make_service( + verifier, + capturing_generator(Arc::clone(&sink)), + mapping_store(mapping(&["read"])), + ) + .exchange_token(PARTICIPANT_CONTEXT, vec!["read".to_string()], "input-token", None) + .await + .unwrap(); + + let claims = sink.lock().unwrap().take().unwrap(); + assert_eq!(claims.custom["act"][crate::k8s::K8S_UID_CLAIM], "uid-123"); +} + // ============================================================================ // Helpers // ============================================================================ diff --git a/crates/jwtlet-postgres/src/resource_store.rs b/crates/jwtlet-postgres/src/resource_store.rs index c3398e6..4167773 100644 --- a/crates/jwtlet-postgres/src/resource_store.rs +++ b/crates/jwtlet-postgres/src/resource_store.rs @@ -234,15 +234,18 @@ impl ResourceStore for PostgresResourceStore { for mapping in &mappings { let claims = Value::Object(mapping.claims.clone()); - sqlx::query( - "INSERT INTO scope_mappings (scope, claims) VALUES ($1, $2) - ON CONFLICT (scope) DO UPDATE SET claims = EXCLUDED.claims", - ) - .bind(&mapping.scope) - .bind(&claims) - .execute(&mut *tx) - .await - .map_err(db_error)?; + sqlx::query("INSERT INTO scope_mappings (scope, claims) VALUES ($1, $2)") + .bind(&mapping.scope) + .bind(&claims) + .execute(&mut *tx) + .await + .map_err(|e| { + if is_unique_violation(&e) { + ResourceError::Conflict(mapping.scope.clone()) + } else { + db_error(e) + } + })?; } tx.commit().await.map_err(db_error)?; diff --git a/crates/jwtlet-postgres/tests/resource_store_tests.rs b/crates/jwtlet-postgres/tests/resource_store_tests.rs index 86866cb..e5887bd 100644 --- a/crates/jwtlet-postgres/tests/resource_store_tests.rs +++ b/crates/jwtlet-postgres/tests/resource_store_tests.rs @@ -12,7 +12,7 @@ #![allow(clippy::unwrap_used)] -use jwtlet_core::resource::{ResourceMapping, ResourceStore, ScopeMapping}; +use jwtlet_core::resource::{ResourceError, ResourceMapping, ResourceStore, ScopeMapping}; use jwtlet_postgres::PostgresResourceStore; use serde_json::{Map, Value, json}; use sqlx::PgPool; @@ -329,7 +329,7 @@ async fn save_scope_mappings_persists_all_entries_in_one_transaction() { } #[tokio::test] -async fn save_scope_mapping_is_upsert() { +async fn save_scope_mapping_conflict_does_not_overwrite() { let (pool, _container) = setup_postgres().await; let store = PostgresResourceStore::new(pool); store.initialize().await.unwrap(); @@ -339,15 +339,38 @@ async fn save_scope_mapping_is_upsert() { .await .unwrap(); - // Save again with different claims — should overwrite, not error. - store + // Creating an existing scope must fail instead of silently replacing its claims. + let result = store .save_scope_mappings(vec![scope_mapping("read", claims(&[("role", json!("editor"))]))]) + .await; + assert!(matches!(result, Err(ResourceError::Conflict(ref s)) if s == "read")); + + let all = store.list_scope_mappings().await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].claims["role"], json!("viewer")); +} + +#[tokio::test] +async fn save_scope_mappings_conflict_rolls_back_whole_batch() { + let (pool, _container) = setup_postgres().await; + let store = PostgresResourceStore::new(pool); + store.initialize().await.unwrap(); + + store + .save_scope_mappings(vec![scope_mapping("read", claims(&[("role", json!("viewer"))]))]) .await .unwrap(); + let result = store + .save_scope_mappings(vec![ + scope_mapping("write", claims(&[("role", json!("editor"))])), + scope_mapping("read", claims(&[("role", json!("editor"))])), + ]) + .await; + assert!(matches!(result, Err(ResourceError::Conflict(_)))); + let all = store.list_scope_mappings().await.unwrap(); - assert_eq!(all.len(), 1); - assert_eq!(all[0].claims["role"], json!("editor")); + assert_eq!(all.len(), 1, "write must not be persisted when the batch fails"); } #[tokio::test] diff --git a/crates/jwtlet-server/Cargo.toml b/crates/jwtlet-server/Cargo.toml index 223ace3..bfcd46e 100644 --- a/crates/jwtlet-server/Cargo.toml +++ b/crates/jwtlet-server/Cargo.toml @@ -26,6 +26,7 @@ opentelemetry_sdk = { workspace = true } opentelemetry-otlp = { workspace = true } opentelemetry-http = { workspace = true } tracing-opentelemetry = { workspace = true } +uuid = { workspace = true } dsdk-facet-core = { workspace = true } dsdk-facet-postgres = { workspace = true } dsdk-facet-hashicorp-vault = { workspace = true } diff --git a/crates/jwtlet-server/src/assembly/mod.rs b/crates/jwtlet-server/src/assembly/mod.rs index 13c39d4..2ca80d7 100644 --- a/crates/jwtlet-server/src/assembly/mod.rs +++ b/crates/jwtlet-server/src/assembly/mod.rs @@ -30,6 +30,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; +use std::time::Duration; use thiserror::Error; use tracing::warn; // ============================================================================ @@ -40,6 +41,9 @@ use tracing::warn; /// The full key name per participant context is `{prefix}-{pc.id}`. pub const DEFAULT_SIGNING_KEY_PREFIX: &str = "signing"; +const K8S_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const K8S_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + // ============================================================================ // Runtime // ============================================================================ @@ -258,6 +262,9 @@ async fn assemble(config: &JwtletConfig, store: Arc) -> Resul ); } + warn_if_api_server_audience(config, "token.client_audience", &client_audience); + warn_if_api_server_audience(config, "management.client_audience", &management_client_audience); + Ok(JwtletRuntime { token_service, resource_service: management_resource_service, @@ -273,6 +280,23 @@ async fn assemble(config: &JwtletConfig, store: Arc) -> Resul // Component Helpers // ============================================================================ +/// Warns when a caller audience equals the API server's own audience. Authorization is still gated by +/// resource mappings and `service_accounts`, but tokens bound to that audience are also valid +/// Kubernetes API credentials, so any token presented to jwtlet could be replayed against the cluster. +fn warn_if_api_server_audience(config: &JwtletConfig, setting: &str, audience: &str) { + let is_api_server_audience = [&config.k8s.cluster_issuer, &config.k8s.api_server_url] + .into_iter() + .flatten() + .any(|a| a.trim_end_matches('/') == audience.trim_end_matches('/')); + if is_api_server_audience { + warn!( + "{setting} '{audience}' is the Kubernetes API server audience — tokens presented to jwtlet \ + are also valid Kubernetes API credentials and could be replayed against the cluster. \ + Prefer a dedicated audience with projected service account tokens." + ); + } +} + fn build_resource_service(store: Arc) -> ResourceService { ResourceService::builder().store(store).build() } @@ -328,17 +352,21 @@ async fn create_k8s_verifier(cfg: &K8sConfig) -> Result PathBuf::from(path), (None, Some(token)) => { - let path = std::env::temp_dir().join("jwtlet_vault_token"); - std::fs::write(&path, token)?; + let path = write_private_temp_file("jwtlet_vault_token", token)?; warn!("Using literal vault token from config — do not use in production"); path } @@ -389,3 +416,20 @@ async fn create_vault_client( Ok(Arc::new(client)) } + +/// Writes `contents` to a new, uniquely named file in the temp directory that only the current user +/// can read. `create_new` refuses to follow a pre-planted file or symlink at that path. +fn write_private_temp_file(prefix: &str, contents: &str) -> std::io::Result { + use std::io::Write; + + let path = std::env::temp_dir().join(format!("{prefix}-{}", uuid::Uuid::new_v4())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(&path)?.write_all(contents.as_bytes())?; + Ok(path) +} diff --git a/crates/jwtlet-server/src/config/mod.rs b/crates/jwtlet-server/src/config/mod.rs index b9e7225..cb57056 100644 --- a/crates/jwtlet-server/src/config/mod.rs +++ b/crates/jwtlet-server/src/config/mod.rs @@ -32,6 +32,7 @@ pub const DEFAULT_BIND_ADDRESS: IpAddr = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)); pub const DEFAULT_SA_TOKEN_FILE: &str = "/var/run/secrets/kubernetes.io/serviceaccount/token"; pub const DEFAULT_PARTICIPANT_CONTEXT_CLAIM: &str = "jwtlet_pc"; pub const DEFAULT_TOKEN_TTL_SECS: i64 = 3600; +pub const MAX_TOKEN_TTL_SECS: i64 = jwtlet_core::token::MAX_TOKEN_TTL_SECS; pub const ENV_CONFIG_FILE: &str = "JWTLET_CONFIG_FILE"; /// Accepted values for `storage_backend.pool.sslmode`. Mirrors libpq / sqlx semantics. @@ -251,10 +252,14 @@ impl JwtletConfig { // K8s configuration match &self.k8s.api_server_url { None => errors.push("k8s.api_server_url is required".to_string()), - Some(url) if url.parse::().is_err() => { - errors.push(format!("k8s.api_server_url is not a valid URL: '{url}'")); - } - _ => {} + Some(url) => match url.parse::() { + Err(_) => errors.push(format!("k8s.api_server_url is not a valid URL: '{url}'")), + // jwtlet authenticates to the API server with its own SA token as a bearer credential. + Ok(parsed) if parsed.scheme() != "https" => { + errors.push(format!("k8s.api_server_url must use https: '{url}'")); + } + Ok(_) => {} + }, } if self.k8s.cluster_issuer.is_none() { @@ -283,6 +288,11 @@ impl JwtletConfig { "token.token_ttl_secs must be positive, got {}", self.token.token_ttl_secs )); + } else if self.token.token_ttl_secs > MAX_TOKEN_TTL_SECS { + errors.push(format!( + "token.token_ttl_secs must not exceed {MAX_TOKEN_TTL_SECS}, got {}", + self.token.token_ttl_secs + )); } // Postgres URL required when using that backend diff --git a/crates/jwtlet-server/src/config/tests/mod.rs b/crates/jwtlet-server/src/config/tests/mod.rs index 69bd16d..1c4c075 100644 --- a/crates/jwtlet-server/src/config/tests/mod.rs +++ b/crates/jwtlet-server/src/config/tests/mod.rs @@ -14,8 +14,8 @@ use crate::config::{ DEFAULT_BIND_ADDRESS, DEFAULT_MANAGEMENT_PORT, DEFAULT_PARTICIPANT_CONTEXT_CLAIM, DEFAULT_SA_TOKEN_FILE, - DEFAULT_TOKEN_EXCHANGE_PORT, DEFAULT_TOKEN_TTL_SECS, JwtletConfig, K8sConfig, PostgresPoolConfig, StorageBackend, - TokenConfig, ValidationError, VaultConfig, + DEFAULT_TOKEN_EXCHANGE_PORT, DEFAULT_TOKEN_TTL_SECS, JwtletConfig, K8sConfig, MAX_TOKEN_TTL_SECS, ManagementConfig, + PostgresPoolConfig, StorageBackend, TokenConfig, ValidationError, VaultConfig, }; use std::time::Duration; @@ -32,7 +32,7 @@ fn valid_config() -> JwtletConfig { token_file: DEFAULT_SA_TOKEN_FILE.to_string(), }, token: TokenConfig { - client_audience: Some("https://kubernetes.default.svc.cluster.local".to_string()), + client_audience: Some("jwtlet-exchange".to_string()), audience: Some("https://my-service.example.com".to_string()), participant_context_claim: DEFAULT_PARTICIPANT_CONTEXT_CLAIM.to_string(), token_ttl_secs: DEFAULT_TOKEN_TTL_SECS, @@ -43,7 +43,9 @@ fn valid_config() -> JwtletConfig { token_file: None, }, service_accounts: Default::default(), - management: Default::default(), + management: ManagementConfig { + client_audience: Some("jwtlet-management".to_string()), + }, } } @@ -398,3 +400,39 @@ fn validation_error_display_includes_all_messages() { assert!(display.contains("second error")); assert!(display.contains('2'.to_string().as_str())); } + +#[test] +fn validate_accepts_missing_management_client_audience() { + // Falls back to token.client_audience at assembly time; only a warning is logged. + let mut cfg = valid_config(); + cfg.management.client_audience = None; + assert!(cfg.validate().is_ok()); +} + +#[test] +fn validate_accepts_management_audience_equal_to_exchange_audience() { + let mut cfg = valid_config(); + cfg.management.client_audience = cfg.token.client_audience.clone(); + assert!(cfg.validate().is_ok()); +} + +#[test] +fn validate_fails_when_token_ttl_exceeds_maximum() { + let mut cfg = valid_config(); + cfg.token.token_ttl_secs = MAX_TOKEN_TTL_SECS + 1; + assert_error_contains(&cfg, "token.token_ttl_secs must not exceed"); +} + +#[test] +fn validate_accepts_token_ttl_at_maximum() { + let mut cfg = valid_config(); + cfg.token.token_ttl_secs = MAX_TOKEN_TTL_SECS; + assert!(cfg.validate().is_ok()); +} + +#[test] +fn validate_fails_when_k8s_api_server_url_is_not_https() { + let mut cfg = valid_config(); + cfg.k8s.api_server_url = Some("http://kubernetes.default.svc".to_string()); + assert_error_contains(&cfg, "k8s.api_server_url must use https"); +} diff --git a/crates/jwtlet-server/src/exchange/mod.rs b/crates/jwtlet-server/src/exchange/mod.rs index f42d127..efaad15 100644 --- a/crates/jwtlet-server/src/exchange/mod.rs +++ b/crates/jwtlet-server/src/exchange/mod.rs @@ -24,6 +24,7 @@ use axum::{ use dsdk_facet_core::jwt::JwkSetProvider; use jwtlet_core::token::TokenExchangeService; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; use std::sync::Arc; const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; @@ -73,16 +74,22 @@ pub async fn token_exchange( return Err(ExchangeApiError::UnsupportedTokenType(form.subject_token_type)); } - let scopes: Vec = form + let scopes: BTreeSet = form .scope .as_deref() .unwrap_or("") .split_whitespace() .map(str::to_string) .collect(); + let granted_scope = (!scopes.is_empty()).then(|| scopes.iter().cloned().collect::>().join(" ")); let token = service - .exchange_token(&form.resource, scopes, &form.subject_token, form.audience) + .exchange_token( + &form.resource, + scopes.into_iter().collect(), + &form.subject_token, + form.audience, + ) .await?; Ok(( @@ -92,7 +99,7 @@ pub async fn token_exchange( issued_token_type: ISSUED_TOKEN_TYPE, token_type: "Bearer", expires_in: service.token_ttl_secs(), - scope: form.scope, + scope: granted_scope, }), )) } diff --git a/crates/jwtlet-server/src/exchange/tests/mod.rs b/crates/jwtlet-server/src/exchange/tests/mod.rs index e1046bd..d869fb1 100644 --- a/crates/jwtlet-server/src/exchange/tests/mod.rs +++ b/crates/jwtlet-server/src/exchange/tests/mod.rs @@ -147,6 +147,30 @@ async fn exchange_token_parses_scope_as_space_separated_list_with_scope_mappings assert_eq!(response.status(), StatusCode::OK); } +#[tokio::test] +async fn exchange_token_returns_normalized_scope() { + let service = make_service( + ok_verifier(), + ok_generator(), + mapping_store(mapping(&["read", "write"])), + ); + let response = token_exchange(State(Arc::new(service)), Form(form(Some(" write read\tread ")))) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await["scope"], "read write"); +} + +#[tokio::test] +async fn exchange_token_omits_scope_when_only_whitespace_requested() { + let service = make_service(ok_verifier(), ok_generator(), mapping_store(mapping(&[]))); + let response = token_exchange(State(Arc::new(service)), Form(form(Some(" ")))) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::OK); + assert!(json_body(response).await.get("scope").is_none()); +} + #[tokio::test] async fn exchange_token_passes_empty_scopes_when_scope_absent() { let service = make_service(ok_verifier(), ok_generator(), mapping_store(mapping(&[]))); diff --git a/crates/jwtlet-server/src/management/error.rs b/crates/jwtlet-server/src/management/error.rs index 80936bd..90cc797 100644 --- a/crates/jwtlet-server/src/management/error.rs +++ b/crates/jwtlet-server/src/management/error.rs @@ -36,6 +36,9 @@ impl IntoResponse for ManagementApiError { Json(json!({"error": format!("Claim key '{key}' is reserved and cannot be set via scope mapping")})), ) .into_response(), + ManagementApiError::Resource(ResourceError::InvalidMapping(ref msg)) => { + (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() + } ManagementApiError::Resource(ResourceError::ClaimConflict(_)) => { StatusCode::INTERNAL_SERVER_ERROR.into_response() } diff --git a/crates/jwtlet-server/src/management/tests/mod.rs b/crates/jwtlet-server/src/management/tests/mod.rs index 36aca00..008a974 100644 --- a/crates/jwtlet-server/src/management/tests/mod.rs +++ b/crates/jwtlet-server/src/management/tests/mod.rs @@ -163,6 +163,35 @@ async fn create_mapping_allows_same_client_in_different_contexts() { assert_eq!(r2.status(), StatusCode::CREATED); } +#[tokio::test] +async fn create_mapping_returns_400_for_empty_client_identifier() { + let router = make_router(); + let resp = post_mapping(&router, mapping_json("", "ctx1")).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn create_mapping_returns_400_for_scope_with_whitespace() { + let router = make_router(); + let mut body = mapping_json("client1", "ctx1"); + body["scopes"] = json!(["read write"]); + let resp = post_mapping(&router, body).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn create_scope_mapping_returns_409_when_scope_exists() { + let router = make_router(); + post_scope(&router, scope_mapping_json("read")).await; + + let response = post_scope( + &router, + scope_mapping_with_claims_json("read", json!({"role": "admin"})), + ) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); +} + // ============================================================================ // H4 — update_mapping validates path params match body // ============================================================================ diff --git a/docs/security-architecture.md b/docs/security-architecture.md index a5d3b18..8e4ed80 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -89,14 +89,27 @@ and `iss` (the cluster OIDC issuer URL). These are the authoritative inputs to t pipeline; no claim from the incoming token is trusted directly. **Audience binding.** The SA token must be projected with the audience matching -`token.client_audience`. TokenReview will reject tokens not bound to this audience. This -prevents replay of SA tokens against other services in the cluster. +`token.client_audience`. Jwtlet additionally requires the TokenReview response's +`status.audiences` to contain the requested audience: authenticators that are not +audience-aware may report a token as authenticated without honoring the requested +audience. This prevents replay of SA tokens against other services in the cluster. +`token.client_audience` should be a dedicated value rather than the API server's own +audience: tokens bound to that audience are also valid Kubernetes API credentials, so a +token presented to Jwtlet could be replayed against the cluster (Jwtlet warns at startup +if it is). + +**Service accounts only.** Jwtlet accepts only Kubernetes service account identities: +the TokenReview user must have a `system:serviceaccount:` username and belong to the +`system:serviceaccounts` group. Identities from other API server authenticators (OIDC +users, static or bootstrap tokens) are rejected. The SA's UID, when returned, is recorded +in the issued token's `act.k8s_uid` claim. **Management API identity.** The management API uses a separate `management.client_audience` value. When configured, management callers must present SA tokens bound to that distinct audience, providing cryptographic separation between exchange callers and management callers. If `management.client_audience` is absent, it falls back to `token.client_audience` — a -configuration Jwtlet warns about at startup. +configuration Jwtlet warns about at startup. Access is then gated only by the +`service_accounts` role list. --- @@ -172,7 +185,13 @@ sub iss aud exp iat nbf act jti ``` These claims are set authoritatively by Jwtlet during token generation and cannot be -overridden by scope expansion. +overridden by scope expansion. The denylist is enforced twice: when scope mappings are +written, and again when a token is issued, so rows written to the store by other means +cannot inject them either. + +**Scope mapping creation.** `POST /api/v1/scopes` fails with `409 Conflict` if a scope +already exists (the whole batch is rejected); existing scopes are changed with `PUT`. +Scope names must be non-empty and contain no whitespace. **Scope claim conflict detection.** If a token exchange request includes multiple scopes whose mappings define the same claim key, the exchange fails with a `409 Conflict` response. Claim @@ -213,17 +232,20 @@ allowlist — see below). ### Processing Pipeline -1. **Verify SA token** — TokenReview against `token.client_audience`. Extracts `sub` and `iss`. +1. **Verify SA token** — TokenReview against `token.client_audience`; the response must confirm + that audience and identify a service account. Extracts `sub`, `iss` and the SA UID. 2. **Resolve mapping** — look up `(sub, resource)` in the resource store, where `resource` is the requested participant context. -3. **Verify scopes** — all requested scopes must be present in the mapping's `scopes` set. +3. **Verify scopes** — requested scopes are deduplicated; all of them must be present in the mapping's `scopes` set. Any scope not in the set returns `401 Unauthorized`. 4. **Expand scopes to claims** — for each requested scope, merge its `ScopeMapping.claims` into the outgoing claims map. Duplicate claim keys across scopes return `409 Conflict`. 5. **Resolve audience** — apply the audience allowlist logic (see below). -6. **Build JWT claims** — assemble `sub`, `aud`, `iat`, `nbf`, `exp`, `act`, and custom claims. +6. **Build JWT claims** — assemble `sub`, `aud`, `iat`, `nbf`, `exp`, `jti`, `act`, and custom claims + (reserved keys are dropped from the custom claims). 7. **Sign via Vault** — call Vault transit `sign` operation with the configured key. -8. **Return** — RFC 8693 response with `access_token` and `token_type=Bearer`. +8. **Return** — RFC 8693 response with `access_token`, `token_type=Bearer` and the normalized + (deduplicated, sorted) `scope`. ### Audience Resolution @@ -252,9 +274,11 @@ targets regardless of what the caller requests. "iat": 1700000000, "nbf": 1700000000, "exp": 1700003600, + "jti": "5f0c6f0e-8d2a-4f7e-9d7e-2b1f4c3a9e10", "act": { "sub": "system:serviceaccount:ns:sa-connector", - "iss": "https://kubernetes.default.svc.cluster.local" + "iss": "https://kubernetes.default.svc.cluster.local", + "k8s_uid": "0d2f6b1e-7c4a-4e59-9b3f-6a8e2c1d5f70" }, "role": "connector-agent", "connector_id": "connector-123" @@ -268,8 +292,9 @@ targets regardless of what the caller requests. | `aud` | Resolved from request + allowlist | | `iat` | Current time | | `nbf` | Current time | -| `exp` | `iat + token_ttl_secs` (default 3600s) | -| `act` | `{sub: SA identity, iss: cluster issuer}` from TokenReview | +| `exp` | `iat + token_ttl_secs` (default 3600s, maximum 86400s) | +| `jti` | Random UUID, unique per issued token | +| `act` | `{sub: SA identity, iss: cluster issuer, k8s_uid: SA UID}` from TokenReview | | *rest* | Merged from scope mapping claim expansion | The `act` claim implements RFC 8693 delegation semantics. It records the K8s SA that @@ -349,12 +374,13 @@ model to be sound: 1. **SA identity is always from TokenReview.** The `act.sub` and the `clientIdentifier` lookup key come exclusively from the TokenReview response. No claim from the incoming - token is used directly. + token is used directly. The response must confirm the requested audience and identify + a Kubernetes service account. 2. **Scope expansion cannot override reserved claims.** The reserved claims denylist (`sub`, `iss`, `aud`, `exp`, `iat`, `nbf`, `act`, `jti`) is enforced at scope mapping write time. A scope mapping that attempts to set any of these is rejected at the - management API with HTTP 400. + management API with HTTP 400. Reserved keys are also dropped at issuance time. 3. **Scopes are all-or-nothing.** The exchange fails if any single requested scope is not present in the mapping. Partial scope grants are not issued. @@ -367,6 +393,7 @@ model to be sound: `management.client_audience` is set to a value distinct from `token.client_audience`, the audience binding on the SA token cryptographically separates the two caller populations. An exchange caller's SA token cannot be replayed against the management API. + Otherwise, management access is gated only by the `service_accounts` role list. 6. **Claim conflicts across scopes are rejected.** If two scopes in the same exchange request expand to the same claim key, the exchange fails rather than silently overwriting. @@ -391,8 +418,8 @@ services. Failure to adhere to these rules invalidates the model's guarantees. - Treat orchestrator SA credentials as CA-equivalent secrets — rotate on any suspected compromise, limit distribution, and audit access -- Configure `management.client_audience` to a value distinct from `token.client_audience` - to enforce cryptographic separation of management and exchange callers +- Use dedicated values for `token.client_audience` and `management.client_audience`, never + the Kubernetes API server audience, and issue projected SA tokens for them - Set `token_ttl_secs` to the shortest value consistent with operational requirements; 3600s (the default) is too long for sensitive participant contexts - Enable `pgaudit` on the Postgres backend in production deployments diff --git a/docs/security-assessment.md b/docs/security-assessment.md new file mode 100644 index 0000000..7bf21aa --- /dev/null +++ b/docs/security-assessment.md @@ -0,0 +1,322 @@ +# Security Assessment: Jwtlet Token Exchange Implementation + +**Date:** 2026-09-30 +**Version reviewed:** 0.0.2 (`34307c7`); findings remediated in the same change set + +This is a code-level security review of the token exchange flow and the +management API. It complements [token-exchange-threat-model.md](token-exchange-threat-model.md), +which analyses the *conceptual model*. This document asks a different question: +*given the model, does the code enforce every check the model relies on?* + +## Scope and Method + +Reviewed crates: + +| Crate | Areas | +|-------------------|---------------------------------------------------------------------------------| +| `jwtlet-core` | `token` (exchange), `resource` (mapping verification), `k8s` (TokenReview), `saccount` | +| `jwtlet-server` | `exchange` / `management` HTTP handlers, `assembly`, `config`, `server` | +| `jwtlet-postgres` | `resource_store` | + +The review traced a `/token` request end to end: form parsing, subject token +verification, mapping resolution, scope-to-claim expansion, audience resolution +and token generation. It also traced the management API authentication and +authorization path, and the startup/configuration path. + +Severity scale: + +| Severity | Meaning | +|----------|---------------------------------------------------------------------------------------------| +| High | A check the security model relies on is missing; exploitable under realistic cluster setups | +| Medium | A weakening of a documented security property, or a missing defence-in-depth layer | +| Low | Hardening, robustness or operational hygiene | + +## Summary + +| ID | Title | Severity | Location | Status | +|-----|------------------------------------------------------------------------------------|------------|------------------------------------------------------|--------| +| F1 | TokenReview response audiences are not verified | High | `jwtlet-core/src/k8s/mod.rs` | Fixed | +| F2 | Any authenticated K8s identity is accepted, not only service accounts | Medium | `jwtlet-core/src/k8s/mod.rs` | Fixed | +| F3 | Management and exchange callers may share a token audience | Medium | `jwtlet-server/src/assembly/mod.rs` | Accepted risk | +| F4 | Reserved claims enforced only on write; custom claims can shadow registered claims | Medium | `jwtlet-core/src/token/mod.rs`, `resource/mod.rs` | Fixed | +| F5 | No `jti`, no upper bound on token TTL | Low–Medium | `jwtlet-core/src/token/mod.rs`, `config/mod.rs` | Fixed | +| F6 | Duplicate requested scopes are not normalized | Low | `jwtlet-core/src/resource/mod.rs`, `exchange/mod.rs` | Fixed | +| F7 | K8s API client has no timeout and no HTTPS enforcement | Low | `jwtlet-server/src/assembly/mod.rs`, `config/mod.rs` | Fixed | +| F8 | Literal Vault token written to a predictable, world-readable temp file | Low | `jwtlet-server/src/assembly/mod.rs` | Fixed | +| F9 | Management input validation gaps; scope "create" silently upserts | Low | `jwtlet-core/src/resource/`, `jwtlet-postgres` | Fixed | +| F10 | Example configuration uses the API server audience as `token.client_audience` | Low | `README.md`, `e2e/manifests/jwtlet-config.yaml` | Mitigated | + +--- + +## Findings + +### F1 — TokenReview response audiences are not verified + +**Severity:** High +**Location:** `crates/jwtlet-core/src/k8s/mod.rs` (`K8sTokenReviewVerifier::verify_token`) + +**Description.** The verifier sends `spec.audiences = [client_audience]` to the +TokenReview API and then only checks `status.authenticated` and `status.user`. +The Kubernetes API contract requires TokenReview clients to verify that +`status.audiences` intersects the requested audiences: authenticators that are +not audience-aware (OIDC, webhook, static and bootstrap tokens, and in some +configurations legacy secret-based SA tokens) can return `authenticated: true` +with the API server's own audiences instead of the requested one. + +**Impact.** Audience binding of the subject token is the primary replay defence +the architecture relies on (trust assumption 1 of the threat model, and +"Audience binding" in `security-architecture.md`). Without the check, any +credential the API server accepts — for example a human operator's `kubectl` +OIDC token or a token leaked from an unrelated integration — is accepted by +`/token` if its username matches a mapping. The management API uses the same +verifier and is equally affected. + +**Recommendation.** Deserialize `status.audiences` and reject the review unless +it contains the requested audience. + +**Resolution.** `TokenReviewStatus` now deserializes `audiences`, and +`verify_token` rejects the review unless it contains the requested audience. + +### F2 — Any authenticated K8s identity is accepted, not only service accounts + +**Severity:** Medium +**Location:** `crates/jwtlet-core/src/k8s/mod.rs` (`UserInfo`), consumed in +`token/mod.rs` and `jwtlet-server/src/management/mod.rs` + +**Description.** Only `status.user.username` is read, and it is used verbatim as +the mapping `client_identifier` and as the management principal. There is no +check that the identity is a service account (`system:serviceaccount:` prefix, +`system:serviceaccounts` group). The SA `uid` is not read either, so a service +account that is deleted and recreated with the same namespace/name inherits all +mappings of its predecessor. + +**Impact.** Widens the set of identities able to call `/token` and the +management API to every authenticator configured on the API server. Combined +with F1, it enables exchanging non-workload credentials. SA recreation +aggravates threat model T6 (stale mappings). + +**Recommendation.** By default, require the identity to be a service account +(prefix and group). Surface `uid` so it can be recorded in `act`; pinning +mappings to a SA UID is a follow-up that changes the storage schema. + +**Resolution.** `UserInfo` now reads `uid` and `groups`. With the new +`require_service_account` builder option (default `true`), the verifier requires +both the `system:serviceaccount:` prefix and the `system:serviceaccounts` group. +The SA UID is propagated as `act.k8s_uid` in issued tokens. Pinning mappings to a +UID remains a follow-up (schema change). + +### F3 — Management and exchange callers may share a token audience + +**Severity:** Medium +**Location:** `crates/jwtlet-server/src/assembly/mod.rs` (`assemble`) + +**Description.** When `management.client_audience` is unset, or equal to +`token.client_audience`, the server logs a warning and continues. Every token +accepted by `/token` is then also a valid bearer for the management API. + +**Impact.** Removes the cryptographic separation between workload SAs and +orchestrator SAs that `security-architecture.md` lists as a security property. +The only remaining barrier is the `service_accounts` role list. + +**Recommendation.** Make `management.client_audience` mandatory and distinct +from `token.client_audience`; fail configuration validation otherwise. + +**Resolution (accepted risk).** Enforcement was briefly added and then reverted: +`management.client_audience` remains optional, falls back to +`token.client_audience`, and a shared audience only produces a startup warning. +The risk is accepted because management access still requires an explicit role in +the `service_accounts` allowlist, and F2 limits callers to service accounts. +Deployments are encouraged to configure a distinct management audience (as the +README and e2e setup do); enforcing it can be revisited later. + +### F4 — Reserved claims enforced only on write; custom claims can shadow registered claims + +**Severity:** Medium (defence in depth) +**Location:** `crates/jwtlet-core/src/token/mod.rs` (`exchange_token`), +`crates/jwtlet-core/src/resource/mod.rs` (`RESERVED_CLAIMS`) + +**Description.** + +- `TokenClaims.custom` is `#[serde(flatten)]`. A custom claim named `sub`, `iss`, + `aud`, `exp`, … produces a JWT payload with **duplicate keys**; most JSON + parsers keep the last occurrence, i.e. the custom value. +- The reserved-claim denylist is enforced only in + `ResourceService::save_scope_mappings` / `update_scope_mapping`. Scope mappings + written directly to Postgres (migrations, restores, manual SQL, other tools) + are copied into issued tokens unfiltered. +**Impact.** A single bad row in `scope_mappings` could override `sub` (the +participant context) or `aud` in issued tokens, bypassing mapping and audience +checks entirely. Relates to threat model T3. + +**Recommendation.** Filter reserved keys again at issuance time, logging a +warning when anything is dropped. + +**Resolution.** `exchange_token` drops any reserved key from scope-derived claims +at issuance time and logs a warning. The denylist itself is unchanged (`sub iss aud +exp iat nbf act jti`): extending it to claims such as `scope` would break the +documented use of scope mappings to build the token's `scope` claim. + +### F5 — No `jti`, no upper bound on token TTL + +**Severity:** Low–Medium +**Location:** `crates/jwtlet-core/src/token/mod.rs`, `crates/jwtlet-server/src/config/mod.rs` + +**Description.** Issued tokens carry no `jti`, so downstream services cannot +detect replay and a future denylist or introspection endpoint has no key. +`token.token_ttl_secs` is only required to be positive, with no maximum. + +**Impact.** Aggravates threat model T5 (transferable bearer tokens) and T7 +(revocation latency equals TTL). A misconfigured TTL silently produces +long-lived tokens. + +**Recommendation.** Add a random `jti` to every issued token and cap +`token_ttl_secs` in configuration validation. + +**Resolution.** Every issued token carries a random UUID `jti`. +`token.token_ttl_secs` is capped at 86400 by configuration validation. Bounding the +issued token's lifetime by the subject token's `exp` is not possible with +TokenReview, which does not return it. + +### F6 — Duplicate requested scopes are not normalized + +**Severity:** Low +**Location:** `crates/jwtlet-core/src/resource/mod.rs` (`ResourceService::verify`), +`crates/jwtlet-server/src/exchange/mod.rs` + +**Description.** A request with `scope=a a` expands scope `a` twice: string +claims are concatenated with themselves (`"x x"`) and non-string claims fail +with a claim conflict. The response echoes the raw `scope` parameter instead of +the granted scope set. + +**Impact.** Malformed claim values and inconsistent error behaviour; no +privilege gain. + +**Recommendation.** Deduplicate requested scopes (ordered set) before expansion +and return the normalized scope string. + +> An empty or missing `scope` yields a token with no scope-derived claims. This +> is intended: `scope` is optional per `security-architecture.md`. + +**Resolution.** Requested scopes are deduplicated into an ordered set in both the +HTTP handler and `ResourceService::verify`; the response `scope` is the normalized, +space-joined set (omitted when empty). + +### F7 — K8s API client has no timeout and no HTTPS enforcement + +**Severity:** Low +**Location:** `crates/jwtlet-server/src/assembly/mod.rs` (`create_k8s_verifier`), +`crates/jwtlet-server/src/config/mod.rs` + +**Description.** The `reqwest` client used for TokenReview has no connect or +request timeout. `k8s.api_server_url` is not required to use `https`, although +Jwtlet's own SA token is sent to it as a bearer credential. + +**Impact.** A slow or unreachable API server stalls every `/token` and +management request (resource exhaustion). A plain-HTTP URL leaks Jwtlet's SA +token. + +**Recommendation.** Configure connect and request timeouts; reject non-`https` +API server URLs. + +**Resolution.** The TokenReview client uses a 5 s connect timeout and a 10 s +request timeout, and warns when the cluster CA is missing. Configuration +validation rejects a non-`https` `k8s.api_server_url`. + +### F8 — Literal Vault token written to a predictable, world-readable temp file + +**Severity:** Low +**Location:** `crates/jwtlet-server/src/assembly/mod.rs` (`create_vault_client`) + +**Description.** When `vault.token` is configured, the token is written to +`$TMPDIR/jwtlet_vault_token` with the default umask (typically `0644`), +following symlinks at a predictable path. + +**Impact.** Local disclosure of the Vault token to other users of the host, and +symlink clobbering. The option is documented as development-only but nothing +prevents its use elsewhere. + +**Recommendation.** Create the file exclusively (`create_new`) with mode `0600` +at an unpredictable path. + +**Resolution.** The token is written with `create_new` and mode `0600` to a +uniquely named file (`jwtlet_vault_token-`). + +### F9 — Management input validation gaps; scope "create" silently upserts + +**Severity:** Low +**Location:** `crates/jwtlet-core/src/resource/mod.rs`, `resource/mem.rs`, +`crates/jwtlet-postgres/src/resource_store.rs` + +**Description.** + +- Resource mappings accept empty `clientIdentifier` / `participantContext`, + empty or whitespace-containing scopes (which can never be requested, since + request scopes are whitespace-separated), and empty audiences. +- `POST /api/v1/scopes` is a create operation but upserts: an existing scope's + claims are silently overwritten, and the audit log records "scope mapping + created". + +**Impact.** Silent policy overwrites and a misleading audit trail (threat model +T8); dead or confusing mapping entries. + +**Recommendation.** Validate mapping fields on create/update. Make scope +creation fail with `409 Conflict` when the scope already exists; updates go +through `PUT`. + +**Resolution.** `ResourceService::save`/`update` reject empty identifiers, empty +or whitespace scope names and empty audiences (`400 Bad Request`). Scope creation +(memory and Postgres stores) fails with `409 Conflict` when a scope already exists +or repeats within the batch, and the batch is rolled back. + +### F10 — Example configuration uses the API server audience as `token.client_audience` + +**Severity:** Low +**Location:** `README.md`, `e2e/manifests/jwtlet-config.yaml` + +**Description.** The documented example and the e2e deployment set +`token.client_audience` to `https://kubernetes.default.svc.cluster.local`, the +Kubernetes API server's own audience. Every automounted SA token carries that +audience. + +**Impact.** Authorization is not weakened: any SA can authenticate, but a token is +only issued if a resource mapping exists, and management access still requires a +role in `service_accounts`. The risk is credential replay outside Jwtlet: every +subject token presented to Jwtlet is also a valid credential against the +Kubernetes API, so anything that captures it — request logging, a proxy or +sidecar, or Jwtlet itself if compromised — can act as that SA against the cluster +with its RBAC permissions. Operators copying the example inherit this setup. + +**Recommendation.** Use a dedicated audience for exchange (and a different one for +management) and issue projected SA tokens for it. + +**Resolution (mitigated).** The README and the e2e manifests/tests now use +dedicated audiences (`jwtlet-exchange`, `jwtlet-management`). Jwtlet logs a warning +at startup when either client audience equals `k8s.cluster_issuer` or +`k8s.api_server_url`. Turning this into a validation error is recommended once +existing deployments have migrated. + +--- + +## Checked and Found Adequate + +- All SQL is parameterized. +- Missing mapping, disallowed scope and disallowed audience all return the same + `403 unauthorized_client` with no description (no enumeration oracle). +- Audience resolution (`resolve_audience`) matches the documented rules. +- `act` is inserted after scope claims and `act` is on the reserved list, so it + cannot be overridden by a scope mapping. +- `grant_type` and `subject_token_type` are validated. +- Bearer header parsing is safe; subject tokens are not logged. +- Request bodies are bounded by axum's default limit (2 MB). +- An empty or missing `scope` is accepted by design (see F6 note). + +## Relation to the Threat Model + +| Finding | Related threat-model item | +|---------|-------------------------------------------------------------| +| F1, F2 | Trust assumption 1 (TokenReview is authoritative) | +| F3 | Two-tier SA model | +| F4 | T3 (scope claims are declarative) | +| F5 | T5 (transferable tokens), T7 (revocation latency) | +| F10 | Trust assumption 1, two-tier SA model | +| F9 | T8 (audit trail) | diff --git a/docs/token-exchange-threat-model.md b/docs/token-exchange-threat-model.md index 2e6cc95..423ed25 100644 --- a/docs/token-exchange-threat-model.md +++ b/docs/token-exchange-threat-model.md @@ -5,6 +5,9 @@ resource/scope mapping model — not a code audit. It asks: is the conceptual model sound, and where does it break regardless of how correctly the code is written? +For the code-level review of how the implementation enforces this model, see +[security-assessment.md](security-assessment.md). + --- ## Model Summary diff --git a/e2e/manifests/jwtlet-config.yaml b/e2e/manifests/jwtlet-config.yaml index 2da88e5..f3207c8 100644 --- a/e2e/manifests/jwtlet-config.yaml +++ b/e2e/manifests/jwtlet-config.yaml @@ -19,9 +19,12 @@ data: cluster_issuer = "https://kubernetes.default.svc.cluster.local" [token] - client_audience = "https://kubernetes.default.svc.cluster.local" + client_audience = "jwtlet-exchange" audience = "jwtlet-e2e" + [management] + client_audience = "jwtlet-management" + [vault] url = "http://vault:8200" token_file = "/vault/secrets/.vault-token" diff --git a/e2e/src/tests/token_exchange.rs b/e2e/src/tests/token_exchange.rs index 85eb000..30da7f1 100644 --- a/e2e/src/tests/token_exchange.rs +++ b/e2e/src/tests/token_exchange.rs @@ -18,7 +18,9 @@ use serde_json::{Value, json}; const PARTICIPANT_CONTEXT: &str = "test-context"; const SA_NAME: &str = "test-app-sa"; // subject_token audience must match jwtlet's token.client_audience config -const CLIENT_AUDIENCE: &str = "https://kubernetes.default.svc.cluster.local"; +const CLIENT_AUDIENCE: &str = "jwtlet-exchange"; +// management bearer token audience must match jwtlet's management.client_audience config +const MGMT_AUDIENCE: &str = "jwtlet-management"; // issued token audience, matches token.audience in jwtlet-config.yaml const TOKEN_AUDIENCE: &str = "jwtlet-e2e"; @@ -51,7 +53,7 @@ async fn test_token_exchange() -> anyhow::Result<()> { let client_identifier = format!("system:serviceaccount:{namespace}:{SA_NAME}"); // Get a management SA token for the management API caller - let mgmt_token = crate::utils::create_service_account_token(SA_NAME, namespace, CLIENT_AUDIENCE)?; + let mgmt_token = crate::utils::create_service_account_token(SA_NAME, namespace, MGMT_AUDIENCE)?; // Register the SA -> participant context mapping with an audience allowlist let mapping = json!({ @@ -118,7 +120,7 @@ async fn test_token_exchange_with_scope_mapping() -> anyhow::Result<()> { let client_identifier = format!("system:serviceaccount:{namespace}:{SA_NAME}"); // Get a management SA token for the management API caller - let mgmt_token = crate::utils::create_service_account_token(SA_NAME, namespace, CLIENT_AUDIENCE)?; + let mgmt_token = crate::utils::create_service_account_token(SA_NAME, namespace, MGMT_AUDIENCE)?; // Register the SA -> participant context mapping with an audience allowlist let resource_mapping = json!({ @@ -225,7 +227,7 @@ async fn test_token_exchange_audience_not_in_allowlist() -> anyhow::Result<()> { let client_identifier = format!("system:serviceaccount:{namespace}:{SA_NAME}"); // Ensure the mapping exists with a restricted audience allowlist - let mgmt_token = crate::utils::create_service_account_token(SA_NAME, namespace, CLIENT_AUDIENCE)?; + let mgmt_token = crate::utils::create_service_account_token(SA_NAME, namespace, MGMT_AUDIENCE)?; let mapping = json!({ "clientIdentifier": client_identifier, "participantContext": PARTICIPANT_CONTEXT, @@ -302,7 +304,7 @@ async fn test_token_jwks_verification() -> anyhow::Result<()> { let client_identifier = format!("system:serviceaccount:{namespace}:{SA_NAME}"); // Register the SA -> participant context mapping (idempotent — may already exist from another test) - let mgmt_token = crate::utils::create_service_account_token(SA_NAME, namespace, CLIENT_AUDIENCE)?; + let mgmt_token = crate::utils::create_service_account_token(SA_NAME, namespace, MGMT_AUDIENCE)?; let mapping = json!({ "clientIdentifier": client_identifier, "participantContext": PARTICIPANT_CONTEXT,