Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <token>` 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 <token>` 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
Expand Down
46 changes: 45 additions & 1 deletion crates/jwtlet-core/src/k8s/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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<Arc<RwLock<SaTokenState>>>,

Expand Down Expand Up @@ -184,18 +195,38 @@ 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(),
aud: audience.to_string(),
iat: 0,
exp: 0,
nbf: None,
custom: Map::new(),
custom,
})
}
}
Expand Down Expand Up @@ -226,9 +257,22 @@ struct TokenReviewStatus {
user: Option<UserInfo>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
audiences: Vec<String>,
}

#[derive(Deserialize)]
struct UserInfo {
username: String,
#[serde(default)]
uid: Option<String>,
#[serde(default)]
groups: Vec<String>,
}

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)
}
}
141 changes: 138 additions & 3 deletions crates/jwtlet-core/src/k8s/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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));
}
8 changes: 7 additions & 1 deletion crates/jwtlet-core/src/resource/mem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -105,6 +105,12 @@ impl ResourceStore for MemoryResourceStore {
async fn save_scope_mappings(&self, mappings: Vec<ScopeMapping>) -> 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);
}
Expand Down
Loading
Loading