diff --git a/crates/jwtlet-server/src/exchange/error.rs b/crates/jwtlet-server/src/exchange/error.rs index 7b6fad6..998a89c 100644 --- a/crates/jwtlet-server/src/exchange/error.rs +++ b/crates/jwtlet-server/src/exchange/error.rs @@ -21,6 +21,8 @@ use thiserror::Error; pub enum ExchangeApiError { #[error("Unsupported grant_type: {0}")] UnsupportedGrantType(String), + #[error("Unsupported subject_token_type: {0}")] + UnsupportedTokenType(String), #[error(transparent)] Exchange(#[from] ExchangeError), } @@ -36,6 +38,7 @@ impl IntoResponse for ExchangeApiError { fn into_response(self) -> Response { let (status, error) = match self { ExchangeApiError::UnsupportedGrantType(_) => (StatusCode::BAD_REQUEST, "unsupported_grant_type"), + ExchangeApiError::UnsupportedTokenType(_) => (StatusCode::BAD_REQUEST, "invalid_request"), ExchangeApiError::Exchange(ExchangeError::TokenVerification(_)) => { (StatusCode::BAD_REQUEST, "invalid_grant") } diff --git a/crates/jwtlet-server/src/exchange/mod.rs b/crates/jwtlet-server/src/exchange/mod.rs index 331b5d6..f42d127 100644 --- a/crates/jwtlet-server/src/exchange/mod.rs +++ b/crates/jwtlet-server/src/exchange/mod.rs @@ -34,6 +34,8 @@ const ISSUED_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:jwt"; pub struct TokenExchangeForm { grant_type: String, subject_token: String, + /// RFC 8693 required parameter identifying the type of `subject_token`. + subject_token_type: String, /// Identifies the participant context the caller is requesting a token for. resource: String, #[serde(default)] @@ -67,6 +69,10 @@ pub async fn token_exchange( return Err(ExchangeApiError::UnsupportedGrantType(form.grant_type)); } + if form.subject_token_type != ISSUED_TOKEN_TYPE { + return Err(ExchangeApiError::UnsupportedTokenType(form.subject_token_type)); + } + let scopes: Vec = form .scope .as_deref() diff --git a/crates/jwtlet-server/src/exchange/tests/mod.rs b/crates/jwtlet-server/src/exchange/tests/mod.rs index efcc3d1..e1046bd 100644 --- a/crates/jwtlet-server/src/exchange/tests/mod.rs +++ b/crates/jwtlet-server/src/exchange/tests/mod.rs @@ -12,7 +12,7 @@ #![allow(clippy::unwrap_used)] -use crate::exchange::{TOKEN_EXCHANGE_GRANT_TYPE, TokenExchangeForm, token_exchange}; +use crate::exchange::{ISSUED_TOKEN_TYPE, TOKEN_EXCHANGE_GRANT_TYPE, TokenExchangeForm, token_exchange}; use async_trait::async_trait; use axum::body::to_bytes; use axum::extract::{Form, State}; @@ -156,6 +156,17 @@ async fn exchange_token_passes_empty_scopes_when_scope_absent() { assert_eq!(response.status(), StatusCode::OK); } +#[tokio::test] +async fn exchange_token_returns_400_for_unsupported_subject_token_type() { + let service = make_service(ok_verifier(), ok_generator(), empty_store()); + let mut f = form(None); + f.subject_token_type = "urn:ietf:params:oauth:token-type:access_token".to_string(); + let response = token_exchange(State(Arc::new(service)), Form(f)).await.into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = json_body(response).await; + assert_eq!(body["error"], "invalid_request"); +} + async fn json_body(response: axum::response::Response) -> serde_json::Value { let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); serde_json::from_slice(&bytes).unwrap() @@ -169,6 +180,7 @@ fn form_with_audience(scope: Option<&str>, audience: Option<&str>) -> TokenExcha TokenExchangeForm { grant_type: TOKEN_EXCHANGE_GRANT_TYPE.to_string(), subject_token: "input.jwt.token".to_string(), + subject_token_type: ISSUED_TOKEN_TYPE.to_string(), resource: PARTICIPANT_CONTEXT.to_string(), scope: scope.map(str::to_string), audience: audience.map(str::to_string), @@ -349,9 +361,15 @@ async fn exchange_token_returns_no_error_description_on_token_verification_failu #[tokio::test] async fn exchange_token_returns_400_for_scope_claim_conflict() { let mut read_claims = Map::new(); - read_claims.insert("role".to_string(), json!({"readOnly": true, "description": "reader description"})); + read_claims.insert( + "role".to_string(), + json!({"readOnly": true, "description": "reader description"}), + ); let mut write_claims = Map::new(); - write_claims.insert("role".to_string(), json!({"readOnly": false, "description": "writer description"})); + write_claims.insert( + "role".to_string(), + json!({"readOnly": false, "description": "writer description"}), + ); let mut scope_mappings = HashMap::new(); scope_mappings.insert( "read".to_string(), diff --git a/crates/jwtlet-server/src/management/tests/mod.rs b/crates/jwtlet-server/src/management/tests/mod.rs index 0b58153..36aca00 100644 --- a/crates/jwtlet-server/src/management/tests/mod.rs +++ b/crates/jwtlet-server/src/management/tests/mod.rs @@ -490,7 +490,11 @@ async fn create_scope_mapping_array_is_atomic_on_invalid_entry() { let resp = get_scopes(&router).await; let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); let stored: Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(stored, json!([]), "no scope mapping should be persisted when the batch fails"); + assert_eq!( + stored, + json!([]), + "no scope mapping should be persisted when the batch fails" + ); } #[traced_test] diff --git a/crates/jwtlet-server/src/server/mod.rs b/crates/jwtlet-server/src/server/mod.rs index c4e5d65..40d959a 100644 --- a/crates/jwtlet-server/src/server/mod.rs +++ b/crates/jwtlet-server/src/server/mod.rs @@ -23,6 +23,8 @@ use dsdk_facet_core::jwt::{JwkSetProvider, JwtVerifier}; use jwtlet_core::resource::ResourceService; use jwtlet_core::saccount::ServiceAccountAuthorizer; use jwtlet_core::token::TokenExchangeService; +use opentelemetry::global; +use opentelemetry_http::HeaderExtractor; use std::net::IpAddr; use std::sync::Arc; use thiserror::Error; @@ -30,8 +32,6 @@ use tokio::net::TcpListener; use tokio::select; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; -use opentelemetry::global; -use opentelemetry_http::HeaderExtractor; use tower_http::trace::TraceLayer; use tracing::{error, info}; use tracing_opentelemetry::OpenTelemetrySpanExt; @@ -168,9 +168,8 @@ async fn health() -> &'static str { /// (`traceparent`/`tracestate`) from the inbound request headers, so jwtlet's spans become children /// of the calling service's span rather than starting a new trace. fn make_http_span(request: &axum::extract::Request) -> tracing::Span { - let parent_cx = global::get_text_map_propagator(|propagator| { - propagator.extract(&HeaderExtractor(request.headers())) - }); + let parent_cx = + global::get_text_map_propagator(|propagator| propagator.extract(&HeaderExtractor(request.headers()))); let span = tracing::info_span!( "http_request", otel.kind = "server", diff --git a/e2e/src/tests/token_exchange.rs b/e2e/src/tests/token_exchange.rs index 701fd44..85eb000 100644 --- a/e2e/src/tests/token_exchange.rs +++ b/e2e/src/tests/token_exchange.rs @@ -83,6 +83,7 @@ async fn test_token_exchange() -> anyhow::Result<()> { let params = [ ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), ("subject_token", sa_token.as_str()), + ("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"), ("resource", PARTICIPANT_CONTEXT), ("scope", "read"), ("audience", TOKEN_AUDIENCE), @@ -190,6 +191,7 @@ async fn test_token_exchange_with_scope_mapping() -> anyhow::Result<()> { let params = [ ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), ("subject_token", sa_token.as_str()), + ("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"), ("resource", PARTICIPANT_CONTEXT), ("scope", "read write"), ("audience", TOKEN_AUDIENCE), @@ -244,6 +246,7 @@ async fn test_token_exchange_audience_not_in_allowlist() -> anyhow::Result<()> { let params = [ ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), ("subject_token", sa_token.as_str()), + ("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"), ("resource", PARTICIPANT_CONTEXT), ("scope", "read"), ("audience", "https://not-in-allowlist.example.com"), @@ -271,6 +274,7 @@ async fn test_token_exchange_unauthorized() -> anyhow::Result<()> { let params = [ ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), ("subject_token", sa_token.as_str()), + ("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"), ("resource", "no-such-context"), ("scope", "read"), ]; @@ -320,6 +324,7 @@ async fn test_token_jwks_verification() -> anyhow::Result<()> { let params = [ ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), ("subject_token", sa_token.as_str()), + ("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"), ("resource", PARTICIPANT_CONTEXT), ("scope", "read"), ];