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
3 changes: 3 additions & 0 deletions crates/jwtlet-core/src/token/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub struct TokenExchangeService {
client_audience: String,
#[builder(into)]
audience: String,
#[builder(into)]
issuer: String,
#[builder(into, default = "jwtlet_pc")]
jwtlet_participant_context: String,
#[builder(default = 3600)]
Expand Down Expand Up @@ -81,6 +83,7 @@ impl TokenExchangeService {
let now = Utc::now().timestamp();
let participant_claims = TokenClaims::builder()
.sub(participant_context)
.iss(self.issuer.as_str())
.aud(aud.as_str())
.iat(now)
.nbf(now)
Expand Down
18 changes: 18 additions & 0 deletions crates/jwtlet-core/src/token/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const ALT_AUDIENCE: &str = "https://other-service.example.com";
const PARTICIPANT_CONTEXT: &str = "test-context";
const CLIENT_SUB: &str = "system:serviceaccount:default:test-sa";
const CLIENT_ISS: &str = "https://kubernetes.default.svc";
const JWTLET_ISSUER: &str = "https://jwtlet.example.com";

// ============================================================================
// Existing exchange_token tests (audience = None — unchanged behaviour)
Expand Down Expand Up @@ -279,6 +280,22 @@ async fn exchange_token_allows_any_audience_from_multi_entry_allowlist() {
}
}

#[tokio::test]
async fn exchange_token_issued_token_includes_iss_claim() {
let sink = Arc::new(Mutex::new(None));
make_service(
ok_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.iss, JWTLET_ISSUER);
}

#[tokio::test]
async fn exchange_token_issued_token_includes_iat_and_nbf() {
let sink = Arc::new(Mutex::new(None));
Expand Down Expand Up @@ -527,6 +544,7 @@ fn make_service(verifier: StubVerifier, generator: StubGenerator, store: StubSto
TokenExchangeService::builder()
.client_audience(CLIENT_AUDIENCE)
.audience(TOKEN_AUDIENCE)
.issuer(JWTLET_ISSUER)
.verifier(Box::new(verifier))
.generator(Box::new(generator))
.resource_service(
Expand Down
9 changes: 9 additions & 0 deletions crates/jwtlet-server/src/assembly/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//

use crate::config::{JwtletConfig, K8sConfig, PostgresPoolConfig, StorageBackend, VaultConfig};
use crate::meta::AuthorizationServerMetadata;
use dsdk_facet_core::jwt::{
JwkSetProvider, JwtGenerator, JwtVerifier, VaultJwtGenerator, VaultVerificationKeyResolver,
};
Expand Down Expand Up @@ -55,6 +56,8 @@ pub struct JwtletRuntime {
pub management_verifier: Arc<dyn JwtVerifier>,
/// Audience used when verifying management Bearer tokens via K8s TokenReview.
pub management_client_audience: String,
/// RFC 8414 authorization server metadata document.
pub metadata: Arc<AuthorizationServerMetadata>,
}

// ============================================================================
Expand Down Expand Up @@ -217,11 +220,16 @@ async fn assemble(config: &JwtletConfig, store: Arc<dyn ResourceStore>) -> Resul
.audience
.clone()
.ok_or_else(|| JwtletError::Configuration("token.audience is required".to_string()))?;
let issuer = config
.issuer
.clone()
.ok_or_else(|| JwtletError::Configuration("issuer is required".to_string()))?;

let token_service = Arc::new(
TokenExchangeService::builder()
.client_audience(client_audience.clone())
.audience(audience)
.issuer(issuer.clone())
.jwtlet_participant_context(config.token.participant_context_claim.clone())
.token_ttl_secs(config.token.token_ttl_secs)
.verifier(Box::new(create_k8s_verifier(&config.k8s).await?))
Expand Down Expand Up @@ -255,6 +263,7 @@ async fn assemble(config: &JwtletConfig, store: Arc<dyn ResourceStore>) -> Resul
service_account_authorizer,
management_verifier,
management_client_audience,
metadata: Arc::new(AuthorizationServerMetadata::new(issuer)),
})
}

Expand Down
14 changes: 14 additions & 0 deletions crates/jwtlet-server/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ pub struct JwtletConfig {
pub management_port: u16,
#[serde(default = "default_bind")]
pub bind: IpAddr,
/// The canonical URL of this server (e.g. `https://jwtlet.example.com`).
/// Used as the `iss` claim in issued tokens and as the base URL in the
/// RFC 8414 authorization server metadata document.
pub issuer: Option<String>,
#[serde(default)]
pub storage_backend: StorageBackend,
#[serde(default)]
Expand All @@ -205,6 +209,7 @@ impl Default for JwtletConfig {
token_exchange_port: DEFAULT_TOKEN_EXCHANGE_PORT,
management_port: DEFAULT_MANAGEMENT_PORT,
bind: DEFAULT_BIND_ADDRESS,
issuer: None,
storage_backend: StorageBackend::Memory,
k8s: K8sConfig::default(),
token: TokenConfig::default(),
Expand All @@ -222,6 +227,15 @@ impl JwtletConfig {
pub fn validate(&self) -> Result<(), ValidationError> {
let mut errors = Vec::new();

// Issuer
match &self.issuer {
None => errors.push("issuer is required".to_string()),
Some(url) if url.parse::<reqwest::Url>().is_err() => {
errors.push(format!("issuer is not a valid URL: '{url}'"));
}
_ => {}
}

// Vault configuration
match &self.vault.url {
None => errors.push("vault.url is required".to_string()),
Expand Down
16 changes: 16 additions & 0 deletions crates/jwtlet-server/src/config/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ fn valid_config() -> JwtletConfig {
token_exchange_port: 8080,
management_port: 8081,
bind: DEFAULT_BIND_ADDRESS,
issuer: Some("https://jwtlet.example.com".to_string()),
storage_backend: StorageBackend::Memory,
k8s: K8sConfig {
api_server_url: Some("https://kubernetes.default.svc".to_string()),
Expand Down Expand Up @@ -82,6 +83,20 @@ fn vault_token_file_accepted_instead_of_literal_token() {
assert!(cfg.validate().is_ok());
}

#[test]
fn validate_fails_when_issuer_missing() {
let mut cfg = valid_config();
cfg.issuer = None;
assert_error_contains(&cfg, "issuer is required");
}

#[test]
fn validate_fails_when_issuer_invalid() {
let mut cfg = valid_config();
cfg.issuer = Some("not-a-url".to_string());
assert_error_contains(&cfg, "issuer is not a valid URL");
}

#[test]
fn validate_fails_when_vault_url_missing() {
let mut cfg = valid_config();
Expand Down Expand Up @@ -355,6 +370,7 @@ fn validate_collects_all_errors_before_returning() {
err.error_count()
);
let msgs = err.messages();
assert!(msgs.iter().any(|m| m.contains("issuer")));
assert!(msgs.iter().any(|m| m.contains("vault.url")));
assert!(msgs.iter().any(|m| m.contains("k8s.api_server_url")));
assert!(msgs.iter().any(|m| m.contains("token.client_audience")));
Expand Down
1 change: 1 addition & 0 deletions crates/jwtlet-server/src/exchange/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ fn make_service(verifier: StubVerifier, generator: StubGenerator, store: StubSto
TokenExchangeService::builder()
.client_audience(CLIENT_AUDIENCE)
.audience(TOKEN_AUDIENCE)
.issuer("https://jwtlet.example.com")
.verifier(Box::new(verifier))
.generator(Box::new(generator))
.resource_service(
Expand Down
1 change: 1 addition & 0 deletions crates/jwtlet-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ pub mod assembly;
pub mod config;
pub mod exchange;
pub mod management;
pub mod meta;
pub mod server;
1 change: 1 addition & 0 deletions crates/jwtlet-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ async fn run(config: JwtletConfig) -> anyhow::Result<()> {
runtime.service_account_authorizer,
runtime.management_verifier,
runtime.management_client_audience,
runtime.metadata,
)
.await
.map_err(Into::into)
Expand Down
45 changes: 45 additions & 0 deletions crates/jwtlet-server/src/meta/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Metaform Systems, Inc
//
// This program and the accompanying materials are made available under the
// terms of the Apache License, Version 2.0 which is available at
// https://www.apache.org/licenses/LICENSE-2.0
//
// SPDX-License-Identifier: Apache-2.0
//
// Contributors:
// Metaform Systems, Inc. - initial API and implementation
//

use axum::{Json, extract::State, response::IntoResponse};
use serde::Serialize;
use std::sync::Arc;

#[derive(Serialize, Clone)]
pub struct AuthorizationServerMetadata {
issuer: String,
token_endpoint: String,
jwks_uri: String,
grant_types_supported: Vec<&'static str>,
token_endpoint_auth_methods_supported: Vec<&'static str>,
}

impl AuthorizationServerMetadata {
pub fn new(issuer: impl Into<String>) -> Self {
let issuer = issuer.into();
let token_endpoint = format!("{issuer}/token");
let jwks_uri = format!("{issuer}/.well-known/jwks.json");
Self {
issuer,
token_endpoint,
jwks_uri,
grant_types_supported: vec!["urn:ietf:params:oauth:grant-type:token-exchange"],
token_endpoint_auth_methods_supported: vec!["none"],
}
}
}

pub async fn get_authorization_server_metadata(
State(meta): State<Arc<AuthorizationServerMetadata>>,
) -> impl IntoResponse {
Json(meta.as_ref().clone())
}
10 changes: 10 additions & 0 deletions crates/jwtlet-server/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use crate::config::JwtletConfig;
use crate::exchange::{get_swk_set, token_exchange};
use crate::management::{ManagementState, management_routes};
use crate::meta::{AuthorizationServerMetadata, get_authorization_server_metadata};
use axum::{
Router,
extract::FromRef,
Expand All @@ -36,6 +37,7 @@ use tracing::{error, info};
struct ExchangeApiState {
token_service: Arc<TokenExchangeService>,
key_resolver: Arc<dyn JwkSetProvider>,
metadata: Arc<AuthorizationServerMetadata>,
}

#[derive(Debug, Error)]
Expand All @@ -52,6 +54,7 @@ pub async fn run_server(
service_account_authorizer: Arc<dyn ServiceAccountAuthorizer>,
management_verifier: Arc<dyn JwtVerifier>,
management_client_audience: String,
metadata: Arc<AuthorizationServerMetadata>,
) -> Result<(), ServerError> {
let cancel_token = CancellationToken::new();
let mut join_set: JoinSet<Result<(), ServerError>> = JoinSet::new();
Expand All @@ -61,6 +64,7 @@ pub async fn run_server(
config.token_exchange_port,
token_service,
key_resolver,
metadata,
cancel_token.clone(),
));

Expand Down Expand Up @@ -101,6 +105,7 @@ async fn run_token_exchange_api(
port: u16,
service: Arc<TokenExchangeService>,
key_resolver: Arc<dyn JwkSetProvider>,
metadata: Arc<AuthorizationServerMetadata>,
cancel: CancellationToken,
) -> Result<(), ServerError> {
let addr = format!("{bind}:{port}");
Expand All @@ -110,11 +115,16 @@ async fn run_token_exchange_api(
let state = ExchangeApiState {
token_service: service,
key_resolver,
metadata,
};
let app = Router::new()
.route("/health", get(health))
.route("/token", post(token_exchange))
.route("/.well-known/jwks.json", get(get_swk_set))
.route(
"/.well-known/oauth-authorization-server",
get(get_authorization_server_metadata),
)
.with_state(state)
.layer(TraceLayer::new_for_http());

Expand Down
2 changes: 2 additions & 0 deletions e2e/manifests/jwtlet-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ metadata:
namespace: vault-e2e-test
data:
jwtlet.toml: |
issuer = "http://jwtlet.vault-e2e-test.svc.cluster.local"
[storage_backend]
type = "postgres"
url = "postgresql://jwtlet:jwtlet@postgres:5432/jwtlet"
Expand Down
94 changes: 94 additions & 0 deletions e2e/src/tests/metadata.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Metaform Systems, Inc
//
// This program and the accompanying materials are made available under the
// terms of the Apache License, Version 2.0 which is available at
// https://www.apache.org/licenses/LICENSE-2.0
//
// SPDX-License-Identifier: Apache-2.0
//
// Contributors:
// Metaform Systems, Inc. - initial API and implementation
//

//! End-to-end tests for the RFC 8414 authorization server metadata endpoint.
use crate::fixtures::jwtlet::ensure_jwtlet_deployed;
use serde_json::Value;

// Must match the top-level `issuer` value in jwtlet-config.yaml.
const ISSUER: &str = "http://jwtlet.vault-e2e-test.svc.cluster.local";

#[tokio::test]
#[cfg_attr(not(feature = "e2e"), ignore)]
async fn test_authorization_server_metadata() -> anyhow::Result<()> {
crate::utils::verify_e2e_setup().await?;

let jwtlet = ensure_jwtlet_deployed().await?;
let client = reqwest::Client::new();

let resp = client
.get(format!(
"http://127.0.0.1:{}/.well-known/oauth-authorization-server",
jwtlet.token_exchange_port
))
.send()
.await?;
assert!(resp.status().is_success(), "metadata fetch failed: {}", resp.status());

let body: Value = resp.json().await?;

assert_eq!(body["issuer"].as_str(), Some(ISSUER), "unexpected issuer");
assert_eq!(
body["token_endpoint"].as_str(),
Some(format!("{ISSUER}/token").as_str()),
"unexpected token_endpoint"
);
assert_eq!(
body["jwks_uri"].as_str(),
Some(format!("{ISSUER}/.well-known/jwks.json").as_str()),
"unexpected jwks_uri"
);
assert_eq!(
body["grant_types_supported"],
serde_json::json!(["urn:ietf:params:oauth:grant-type:token-exchange"]),
"unexpected grant_types_supported"
);
assert_eq!(
body["token_endpoint_auth_methods_supported"],
serde_json::json!(["none"]),
"unexpected token_endpoint_auth_methods_supported"
);

Ok(())
}

#[tokio::test]
#[cfg_attr(not(feature = "e2e"), ignore)]
async fn test_jwks_endpoint_returns_keys() -> anyhow::Result<()> {
crate::utils::verify_e2e_setup().await?;

let jwtlet = ensure_jwtlet_deployed().await?;
let client = reqwest::Client::new();

let resp = client
.get(format!(
"http://127.0.0.1:{}/.well-known/jwks.json",
jwtlet.token_exchange_port
))
.send()
.await?;
assert!(resp.status().is_success(), "JWKS fetch failed: {}", resp.status());

let jwks: jsonwebtoken::jwk::JwkSet = resp.json().await?;
assert!(!jwks.keys.is_empty(), "JWKS contains no keys");

// Every advertised key must carry a kid so issued tokens can reference it.
for jwk in &jwks.keys {
assert!(
jwk.common.key_id.as_deref().is_some_and(|k| !k.is_empty()),
"JWKS key is missing a kid"
);
}

Ok(())
}
Loading
Loading