diff --git a/crates/jwtlet-core/src/token/mod.rs b/crates/jwtlet-core/src/token/mod.rs index aabdbba..f4d7870 100644 --- a/crates/jwtlet-core/src/token/mod.rs +++ b/crates/jwtlet-core/src/token/mod.rs @@ -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)] @@ -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) diff --git a/crates/jwtlet-core/src/token/tests/mod.rs b/crates/jwtlet-core/src/token/tests/mod.rs index 255f1a6..96236c5 100644 --- a/crates/jwtlet-core/src/token/tests/mod.rs +++ b/crates/jwtlet-core/src/token/tests/mod.rs @@ -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) @@ -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)); @@ -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( diff --git a/crates/jwtlet-server/src/assembly/mod.rs b/crates/jwtlet-server/src/assembly/mod.rs index d56b7d0..908c6cb 100644 --- a/crates/jwtlet-server/src/assembly/mod.rs +++ b/crates/jwtlet-server/src/assembly/mod.rs @@ -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, }; @@ -55,6 +56,8 @@ pub struct JwtletRuntime { pub management_verifier: Arc, /// Audience used when verifying management Bearer tokens via K8s TokenReview. pub management_client_audience: String, + /// RFC 8414 authorization server metadata document. + pub metadata: Arc, } // ============================================================================ @@ -217,11 +220,16 @@ async fn assemble(config: &JwtletConfig, store: Arc) -> 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?)) @@ -255,6 +263,7 @@ async fn assemble(config: &JwtletConfig, store: Arc) -> Resul service_account_authorizer, management_verifier, management_client_audience, + metadata: Arc::new(AuthorizationServerMetadata::new(issuer)), }) } diff --git a/crates/jwtlet-server/src/config/mod.rs b/crates/jwtlet-server/src/config/mod.rs index 42615a8..b9e7225 100644 --- a/crates/jwtlet-server/src/config/mod.rs +++ b/crates/jwtlet-server/src/config/mod.rs @@ -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, #[serde(default)] pub storage_backend: StorageBackend, #[serde(default)] @@ -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(), @@ -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::().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()), diff --git a/crates/jwtlet-server/src/config/tests/mod.rs b/crates/jwtlet-server/src/config/tests/mod.rs index 1f6d76c..69bd16d 100644 --- a/crates/jwtlet-server/src/config/tests/mod.rs +++ b/crates/jwtlet-server/src/config/tests/mod.rs @@ -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()), @@ -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(); @@ -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"))); diff --git a/crates/jwtlet-server/src/exchange/tests/mod.rs b/crates/jwtlet-server/src/exchange/tests/mod.rs index feb3096..c77280a 100644 --- a/crates/jwtlet-server/src/exchange/tests/mod.rs +++ b/crates/jwtlet-server/src/exchange/tests/mod.rs @@ -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( diff --git a/crates/jwtlet-server/src/lib.rs b/crates/jwtlet-server/src/lib.rs index 33fa732..42bf036 100644 --- a/crates/jwtlet-server/src/lib.rs +++ b/crates/jwtlet-server/src/lib.rs @@ -14,4 +14,5 @@ pub mod assembly; pub mod config; pub mod exchange; pub mod management; +pub mod meta; pub mod server; diff --git a/crates/jwtlet-server/src/main.rs b/crates/jwtlet-server/src/main.rs index 995ab80..848ebab 100644 --- a/crates/jwtlet-server/src/main.rs +++ b/crates/jwtlet-server/src/main.rs @@ -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) diff --git a/crates/jwtlet-server/src/meta/mod.rs b/crates/jwtlet-server/src/meta/mod.rs new file mode 100644 index 0000000..4b73fb0 --- /dev/null +++ b/crates/jwtlet-server/src/meta/mod.rs @@ -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) -> 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>, +) -> impl IntoResponse { + Json(meta.as_ref().clone()) +} diff --git a/crates/jwtlet-server/src/server/mod.rs b/crates/jwtlet-server/src/server/mod.rs index c9f8830..703b75c 100644 --- a/crates/jwtlet-server/src/server/mod.rs +++ b/crates/jwtlet-server/src/server/mod.rs @@ -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, @@ -36,6 +37,7 @@ use tracing::{error, info}; struct ExchangeApiState { token_service: Arc, key_resolver: Arc, + metadata: Arc, } #[derive(Debug, Error)] @@ -52,6 +54,7 @@ pub async fn run_server( service_account_authorizer: Arc, management_verifier: Arc, management_client_audience: String, + metadata: Arc, ) -> Result<(), ServerError> { let cancel_token = CancellationToken::new(); let mut join_set: JoinSet> = JoinSet::new(); @@ -61,6 +64,7 @@ pub async fn run_server( config.token_exchange_port, token_service, key_resolver, + metadata, cancel_token.clone(), )); @@ -101,6 +105,7 @@ async fn run_token_exchange_api( port: u16, service: Arc, key_resolver: Arc, + metadata: Arc, cancel: CancellationToken, ) -> Result<(), ServerError> { let addr = format!("{bind}:{port}"); @@ -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()); diff --git a/e2e/manifests/jwtlet-config.yaml b/e2e/manifests/jwtlet-config.yaml index f62d31d..2da88e5 100644 --- a/e2e/manifests/jwtlet-config.yaml +++ b/e2e/manifests/jwtlet-config.yaml @@ -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" diff --git a/e2e/src/tests/metadata.rs b/e2e/src/tests/metadata.rs new file mode 100644 index 0000000..a363f2e --- /dev/null +++ b/e2e/src/tests/metadata.rs @@ -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(()) +} diff --git a/e2e/src/tests/mod.rs b/e2e/src/tests/mod.rs index 04fa00d..5c9caeb 100644 --- a/e2e/src/tests/mod.rs +++ b/e2e/src/tests/mod.rs @@ -1,4 +1,5 @@ // Copyright (c) 2026 Metaform Systems, Inc // SPDX-License-Identifier: Apache-2.0 +pub mod metadata; pub mod token_exchange;