diff --git a/Makefile b/Makefile index 560655c..8bfecb7 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ setup: [ ! -f .env ] && cp -v .env.example .env check: + docker-compose up -d cargo clippy cargo test - + docker-compose down diff --git a/hack/roundtrip-azure-obo.sh b/hack/roundtrip-azure-obo.sh index df8dfb9..257f409 100755 --- a/hack/roundtrip-azure-obo.sh +++ b/hack/roundtrip-azure-obo.sh @@ -2,7 +2,7 @@ user_token_response=$(curl -s -X POST http://localhost:8080/azuread/token -d "grant_type=authorization_code&code=yolo&client_id=yolo&client_secret=bolo") user_token=$(echo ${user_token_response} | jq -r .access_token) -response=$(curl -s -X POST http://localhost:3000/token_exchange -H "content-type: application/json" -d '{"target": "my-target", "identity_provider": "azuread", "user_token": "'${user_token}'"}') +response=$(curl -s -X POST http://localhost:3000/token/exchange -H "content-type: application/json" -d '{"target": "my-target", "identity_provider": "azuread", "user_token": "'${user_token}'"}') token=$(echo ${response} | jq -r .access_token) validation=$(curl -s -X POST http://localhost:3000/introspect -H "content-type: application/json" -d "{\"token\": \"${token}\"}") diff --git a/hack/roundtrip-tokenx.sh b/hack/roundtrip-tokenx.sh index 7f066fa..47357e5 100755 --- a/hack/roundtrip-tokenx.sh +++ b/hack/roundtrip-tokenx.sh @@ -2,7 +2,7 @@ user_token_response=$(curl -s -X POST http://localhost:8080/tokenx/token -d "grant_type=authorization_code&code=yolo&client_id=yolo&client_secret=bolo") user_token=$(echo ${user_token_response} | jq -r .access_token) -response=$(curl -s -X POST http://localhost:3000/token_exchange -H "content-type: application/json" -d '{"target": "my-target", "identity_provider": "tokenx", "user_token": "'${user_token}'"}') +response=$(curl -s -X POST http://localhost:3000/token/exchange -H "content-type: application/json" -d '{"target": "my-target", "identity_provider": "tokenx", "user_token": "'${user_token}'"}') token=$(echo ${response} | jq -r .access_token) validation=$(curl -s -X POST http://localhost:3000/introspect -H "content-type: application/json" -d "{\"token\": \"${token}\"}") diff --git a/src/handlers.rs b/src/handlers.rs index 0c98310..9a16a7c 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -3,7 +3,7 @@ use axum::{async_trait, Form, RequestExt}; use crate::config::Config; use crate::identity_provider::*; -use crate::types; +use crate::{jwks, types}; use crate::types::{IdentityProvider, IntrospectRequest, TokenExchangeRequest, TokenRequest}; use axum::extract::State; use axum::http::header::CONTENT_TYPE; @@ -13,7 +13,7 @@ use axum::Json; use jsonwebtoken as jwt; use jsonwebtoken::Algorithm::RS512; use jsonwebtoken::DecodingKey; -use log::{error}; +use log::{error, info}; use std::sync::Arc; use thiserror::Error; use tokio::sync::RwLock; @@ -37,9 +37,9 @@ pub async fn token_exchange( JsonOrForm(request): JsonOrForm, ) -> impl IntoResponse { match &request.identity_provider { - IdentityProvider::AzureAD => state.azure_ad_obo.read().await.exchange_token(request.into()).await.into_response(), + IdentityProvider::AzureAD => state.azure_ad_obo.read().await.exchange_token(request).await.into_response(), IdentityProvider::Maskinporten => (StatusCode::BAD_REQUEST, "Maskinporten does not support token exchange".to_string()).into_response(), - IdentityProvider::TokenX => state.token_x.read().await.exchange_token(request.into()).await.into_response(), + IdentityProvider::TokenX => state.token_x.read().await.exchange_token(request).await.into_response(), } } @@ -92,6 +92,69 @@ pub struct HandlerState { pub token_x: Arc>>, } +#[derive(Error, Debug)] +pub enum InitError { + #[error("invalid private JWK format")] + Jwk, + + #[error("fetch JWKS from remote endpoint: {0}")] + Jwks(#[from] jwks::Error), +} + +impl HandlerState { + pub async fn from_config(cfg: Config) -> Result { + // TODO: we should be able to conditionally enable certain providers based on the configuration + info!("Fetch JWKS for Maskinporten..."); + let maskinporten: Provider = Provider::new( + cfg.maskinporten_issuer.clone(), + cfg.maskinporten_client_id.clone(), + cfg.maskinporten_token_endpoint.clone(), + cfg.maskinporten_client_jwk.clone(), + jwks::Jwks::new(&cfg.maskinporten_issuer.clone(), &cfg.maskinporten_jwks_uri.clone()) + .await?, + ).ok_or(InitError::Jwk)?; + + // TODO: these two AAD providers should be a single provider, but we need to figure out how to handle the different token requests + info!("Fetch JWKS for Azure AD (on behalf of)..."); + let azure_ad_obo: Provider = Provider::new( + cfg.azure_ad_issuer.clone(), + cfg.azure_ad_client_id.clone(), + cfg.azure_ad_token_endpoint.clone(), + cfg.azure_ad_client_jwk.clone(), + jwks::Jwks::new(&cfg.azure_ad_issuer.clone(), &cfg.azure_ad_jwks_uri.clone()) + .await?, + ).ok_or(InitError::Jwk)?; + + info!("Fetch JWKS for Azure AD (client credentials)..."); + let azure_ad_cc: Provider = Provider::new( + cfg.azure_ad_issuer.clone(), + cfg.azure_ad_client_id.clone(), + cfg.azure_ad_token_endpoint.clone(), + cfg.azure_ad_client_jwk.clone(), + jwks::Jwks::new(&cfg.azure_ad_issuer.clone(), &cfg.azure_ad_jwks_uri.clone()) + .await?, + ).ok_or(InitError::Jwk)?; + + info!("Fetch JWKS for TokenX..."); + let token_x: Provider = Provider::new( + cfg.token_x_issuer.clone(), + cfg.token_x_client_id.clone(), + cfg.token_x_token_endpoint.clone(), + cfg.token_x_client_jwk.clone(), + jwks::Jwks::new(&cfg.token_x_issuer.clone(), &cfg.token_x_jwks_uri.clone()) + .await?, + ).ok_or(InitError::Jwk)?; + + Ok(Self { + cfg, + maskinporten: Arc::new(RwLock::new(maskinporten)), + azure_ad_obo: Arc::new(RwLock::new(azure_ad_obo)), + azure_ad_cc: Arc::new(RwLock::new(azure_ad_cc)), + token_x: Arc::new(RwLock::new(token_x)), + }) + } +} + #[derive(Debug, Error)] pub enum ApiError { #[error("identity provider error: {0}")] diff --git a/src/identity_provider.rs b/src/identity_provider.rs index 561e379..c5a4295 100644 --- a/src/identity_provider.rs +++ b/src/identity_provider.rs @@ -129,7 +129,6 @@ pub struct Provider { _fake_assertion: PhantomData, } -//impl Provider where T: TokenRequestFactory + Serialize impl Provider where R: Serialize + TokenRequestFactory, diff --git a/src/jwks.rs b/src/jwks.rs index eef59fd..4bb9005 100644 --- a/src/jwks.rs +++ b/src/jwks.rs @@ -3,6 +3,7 @@ use jsonwebtoken as jwt; use serde::Deserialize; use serde_json::Value; use std::collections::HashMap; +use thiserror::Error; #[derive(Clone, Debug)] pub struct Jwks { @@ -12,13 +13,19 @@ pub struct Jwks { } // TODO: some of these errors relate to the keyset itself, some of it relates to validation of a JWT - are we conflating two things here? -#[derive(Debug)] +#[derive(Error, Debug)] pub enum Error { + #[error("fetch: {0}")] Fetch(reqwest::Error), + #[error("decode json: {0}")] JsonDecode(reqwest::Error), + #[error("json web key set has key with blank key id")] MissingKeyID, + #[error("signing key with {0} not in json web key set")] + KeyNotInJWKS(String), + #[error("invalid token header: {0}")] InvalidTokenHeader(jwt::errors::Error), - KeyNotInJWKS, + #[error("invalid token: {0}")] InvalidToken(jwt::errors::Error), } @@ -80,7 +87,7 @@ impl Jwks { let signing_key = match self.keys.get(&key_id) { None => { self.refresh().await?; - self.keys.get(&key_id).ok_or(Error::KeyNotInJWKS)? + self.keys.get(&key_id).ok_or(Error::KeyNotInJWKS(key_id))? } Some(key) => key, }; diff --git a/src/main.rs b/src/main.rs index 6943ac2..22172c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,15 +5,11 @@ pub mod types; mod claims; mod router; -use crate::claims::{ClientAssertion, JWTBearerAssertion}; use crate::config::Config; -use crate::identity_provider::{AzureADClientCredentialsTokenRequest, AzureADOnBehalfOfTokenRequest, MaskinportenTokenRequest, TokenXTokenRequest}; use clap::Parser; use dotenv::dotenv; -use identity_provider::Provider; use log::{info, LevelFilter}; -use std::sync::Arc; -use tokio::sync::RwLock; +use crate::handlers::HandlerState; pub mod config { use clap::Parser; @@ -59,6 +55,31 @@ pub mod config { #[arg(env)] pub token_x_token_endpoint: String, } + + #[cfg(test)] + impl Config { + pub fn mock() -> Self { + Self { + bind_address: "127.0.0.1:0".to_string(), + maskinporten_client_id: "client-id".to_string(), + maskinporten_client_jwk: r#"{"p":"_LNnIjBshCrFuxtjUC2KKzg_NTVv26UZh5j12_9r5mYTxb8yW047jOYFEGvIdMkTRLGOBig6fLWzgd62lnLainzV35J6K6zr4jQfTldLondlkldMR6nQrp1KfnNUuRbKvzpNKkhl12-f1l91l0tCx3s4blztvWgdzN2xBfvWV68","kty":"RSA","q":"9MIWsbIA3WjiR_Ful5FM8NCgb6JdS2D6ySHVepoNI-iAPilcltF_J2orjfLqAxeztTskPi45wtF_-eV4GIYSzvMo-gFiXLMrvEa7WaWizMi_7Bu9tEk3m_f3IDLN9lwULYoebkDbiXx6GOiuj0VkuKz8ckYFNKLCMP9QRLFff-0","d":"J6UX848X8tNz-09PFvcFDUVqak32GXzoPjnuDjBsxNUvG7LxenLmM_i8tvYl0EW9Ztn4AiCqJUoHw5cX3jz_mSqGl7ciaDedpKm_AetcZwHiEuT1EpSKRPMmOMQSqcJqXrdbbWB8gdUrnTKZIlJCfj7yqgT16ypC43TnwjA0UwxhG5pHaYjKI3pPdoHg2BzA-iubHjVn15Sz7-pnjBmeGDbEFa7ADY-1yPHCmqqvPKTNhoCNW6RpG34Id9hXslPa3X-7pAhJrDBd0_NPlktSA2rUkifYiZURhHR5ijhe0v3uw6kYP8f_foVm_C8O1ExkxXh9Dg8KDZ89dbsSOtBc0Q","e":"AQAB","use":"sig","kid":"l7C_WJgbZ_6e59vPrFETAehX7Dsp7fIyvSV4XhotsGs","qi":"cQFN5q5WhYkzgd1RS0rGqvpX1AkmZMrLv2MW04gSfu0dDwpbsSAu8EUCQW9oA4pr6V7R9CBSu9kdN2iY5SR-hZvEad5nDKPV1F3TMQYv5KpRiS_0XhfV5PcolUJVO_4p3h8d-mo2hh1Sw2fairAKOzvnwJCQ6DFkiY7H1cqwA54","dp":"YTql9AGtvyy158gh7jeXcgmySEbHQzvDFulDr-IXIg8kjHGEbp0rTIs0Z50RA95aC5RFkRjpaBKBfvaySjDm5WIi6GLzntpp6B8l7H6qG1jVO_la4Df2kzjx8LVvY8fhOrKz_hDdHodUeKdCF3RdvWMr00ruLnJhBPJHqoW7cwE","alg":"RS256","dq":"IZA4AngRbEtEtG7kJn6zWVaSmZxfRMXwvgIYvy4-3Qy2AVA0tS3XTPVfMaD8_B2U9CY_CxPVseR-sysHc_12uNBZbycfcOzU84WTjXCMSZ7BysPnGMDtkkLHra-p1L29upz1HVNhh5H9QEswHM98R2LZX2ZAsn4bORLZ1AGqweU","n":"8ZqUp5Cs90XpNn8tJBdUUxdGH4bjqKjFj8lyB3x50RpTuECuwzX1NpVqyFENDiEtMja5fdmJl6SErjnhj6kbhcmfmFibANuG-0WlV5yMysdSbocd75C1JQbiPdpHdXrijmVFMfDnoZTQ-ErNsqqngTNkn5SXBcPenli6Cf9MTSchZuh_qFj_B7Fp3CWKehTiyBcLlNOIjYsXX8WQjZkWKGpQ23AWjZulngWRektLcRWuEKTWaRBtbAr3XAfSmcqTICrebaD3IMWKHDtvzHAt_pt4wnZ06clgeO2Wbc980usnpsF7g8k9p81RcbS4JEZmuuA9NCmOmbyADXwgA9_-Aw"}"#.to_string(), + maskinporten_jwks_uri: "http://localhost:8080/maskinporten/jwks".to_string(), + maskinporten_issuer: "http://localhost:8080/maskinporten".to_string(), + maskinporten_token_endpoint: "http://localhost:8080/maskinporten/token".to_string(), + azure_ad_client_id: "client-id".to_string(), + azure_ad_client_jwk: r#"{"p":"_LNnIjBshCrFuxtjUC2KKzg_NTVv26UZh5j12_9r5mYTxb8yW047jOYFEGvIdMkTRLGOBig6fLWzgd62lnLainzV35J6K6zr4jQfTldLondlkldMR6nQrp1KfnNUuRbKvzpNKkhl12-f1l91l0tCx3s4blztvWgdzN2xBfvWV68","kty":"RSA","q":"9MIWsbIA3WjiR_Ful5FM8NCgb6JdS2D6ySHVepoNI-iAPilcltF_J2orjfLqAxeztTskPi45wtF_-eV4GIYSzvMo-gFiXLMrvEa7WaWizMi_7Bu9tEk3m_f3IDLN9lwULYoebkDbiXx6GOiuj0VkuKz8ckYFNKLCMP9QRLFff-0","d":"J6UX848X8tNz-09PFvcFDUVqak32GXzoPjnuDjBsxNUvG7LxenLmM_i8tvYl0EW9Ztn4AiCqJUoHw5cX3jz_mSqGl7ciaDedpKm_AetcZwHiEuT1EpSKRPMmOMQSqcJqXrdbbWB8gdUrnTKZIlJCfj7yqgT16ypC43TnwjA0UwxhG5pHaYjKI3pPdoHg2BzA-iubHjVn15Sz7-pnjBmeGDbEFa7ADY-1yPHCmqqvPKTNhoCNW6RpG34Id9hXslPa3X-7pAhJrDBd0_NPlktSA2rUkifYiZURhHR5ijhe0v3uw6kYP8f_foVm_C8O1ExkxXh9Dg8KDZ89dbsSOtBc0Q","e":"AQAB","use":"sig","kid":"l7C_WJgbZ_6e59vPrFETAehX7Dsp7fIyvSV4XhotsGs","qi":"cQFN5q5WhYkzgd1RS0rGqvpX1AkmZMrLv2MW04gSfu0dDwpbsSAu8EUCQW9oA4pr6V7R9CBSu9kdN2iY5SR-hZvEad5nDKPV1F3TMQYv5KpRiS_0XhfV5PcolUJVO_4p3h8d-mo2hh1Sw2fairAKOzvnwJCQ6DFkiY7H1cqwA54","dp":"YTql9AGtvyy158gh7jeXcgmySEbHQzvDFulDr-IXIg8kjHGEbp0rTIs0Z50RA95aC5RFkRjpaBKBfvaySjDm5WIi6GLzntpp6B8l7H6qG1jVO_la4Df2kzjx8LVvY8fhOrKz_hDdHodUeKdCF3RdvWMr00ruLnJhBPJHqoW7cwE","alg":"RS256","dq":"IZA4AngRbEtEtG7kJn6zWVaSmZxfRMXwvgIYvy4-3Qy2AVA0tS3XTPVfMaD8_B2U9CY_CxPVseR-sysHc_12uNBZbycfcOzU84WTjXCMSZ7BysPnGMDtkkLHra-p1L29upz1HVNhh5H9QEswHM98R2LZX2ZAsn4bORLZ1AGqweU","n":"8ZqUp5Cs90XpNn8tJBdUUxdGH4bjqKjFj8lyB3x50RpTuECuwzX1NpVqyFENDiEtMja5fdmJl6SErjnhj6kbhcmfmFibANuG-0WlV5yMysdSbocd75C1JQbiPdpHdXrijmVFMfDnoZTQ-ErNsqqngTNkn5SXBcPenli6Cf9MTSchZuh_qFj_B7Fp3CWKehTiyBcLlNOIjYsXX8WQjZkWKGpQ23AWjZulngWRektLcRWuEKTWaRBtbAr3XAfSmcqTICrebaD3IMWKHDtvzHAt_pt4wnZ06clgeO2Wbc980usnpsF7g8k9p81RcbS4JEZmuuA9NCmOmbyADXwgA9_-Aw"}"#.to_string(), + azure_ad_jwks_uri: "http://localhost:8080/azuread/jwks".to_string(), + azure_ad_issuer: "http://localhost:8080/azuread".to_string(), + azure_ad_token_endpoint: "http://localhost:8080/azuread/token".to_string(), + token_x_client_id: "client-id".to_string(), + token_x_client_jwk: r#"{"p":"_LNnIjBshCrFuxtjUC2KKzg_NTVv26UZh5j12_9r5mYTxb8yW047jOYFEGvIdMkTRLGOBig6fLWzgd62lnLainzV35J6K6zr4jQfTldLondlkldMR6nQrp1KfnNUuRbKvzpNKkhl12-f1l91l0tCx3s4blztvWgdzN2xBfvWV68","kty":"RSA","q":"9MIWsbIA3WjiR_Ful5FM8NCgb6JdS2D6ySHVepoNI-iAPilcltF_J2orjfLqAxeztTskPi45wtF_-eV4GIYSzvMo-gFiXLMrvEa7WaWizMi_7Bu9tEk3m_f3IDLN9lwULYoebkDbiXx6GOiuj0VkuKz8ckYFNKLCMP9QRLFff-0","d":"J6UX848X8tNz-09PFvcFDUVqak32GXzoPjnuDjBsxNUvG7LxenLmM_i8tvYl0EW9Ztn4AiCqJUoHw5cX3jz_mSqGl7ciaDedpKm_AetcZwHiEuT1EpSKRPMmOMQSqcJqXrdbbWB8gdUrnTKZIlJCfj7yqgT16ypC43TnwjA0UwxhG5pHaYjKI3pPdoHg2BzA-iubHjVn15Sz7-pnjBmeGDbEFa7ADY-1yPHCmqqvPKTNhoCNW6RpG34Id9hXslPa3X-7pAhJrDBd0_NPlktSA2rUkifYiZURhHR5ijhe0v3uw6kYP8f_foVm_C8O1ExkxXh9Dg8KDZ89dbsSOtBc0Q","e":"AQAB","use":"sig","kid":"l7C_WJgbZ_6e59vPrFETAehX7Dsp7fIyvSV4XhotsGs","qi":"cQFN5q5WhYkzgd1RS0rGqvpX1AkmZMrLv2MW04gSfu0dDwpbsSAu8EUCQW9oA4pr6V7R9CBSu9kdN2iY5SR-hZvEad5nDKPV1F3TMQYv5KpRiS_0XhfV5PcolUJVO_4p3h8d-mo2hh1Sw2fairAKOzvnwJCQ6DFkiY7H1cqwA54","dp":"YTql9AGtvyy158gh7jeXcgmySEbHQzvDFulDr-IXIg8kjHGEbp0rTIs0Z50RA95aC5RFkRjpaBKBfvaySjDm5WIi6GLzntpp6B8l7H6qG1jVO_la4Df2kzjx8LVvY8fhOrKz_hDdHodUeKdCF3RdvWMr00ruLnJhBPJHqoW7cwE","alg":"RS256","dq":"IZA4AngRbEtEtG7kJn6zWVaSmZxfRMXwvgIYvy4-3Qy2AVA0tS3XTPVfMaD8_B2U9CY_CxPVseR-sysHc_12uNBZbycfcOzU84WTjXCMSZ7BysPnGMDtkkLHra-p1L29upz1HVNhh5H9QEswHM98R2LZX2ZAsn4bORLZ1AGqweU","n":"8ZqUp5Cs90XpNn8tJBdUUxdGH4bjqKjFj8lyB3x50RpTuECuwzX1NpVqyFENDiEtMja5fdmJl6SErjnhj6kbhcmfmFibANuG-0WlV5yMysdSbocd75C1JQbiPdpHdXrijmVFMfDnoZTQ-ErNsqqngTNkn5SXBcPenli6Cf9MTSchZuh_qFj_B7Fp3CWKehTiyBcLlNOIjYsXX8WQjZkWKGpQ23AWjZulngWRektLcRWuEKTWaRBtbAr3XAfSmcqTICrebaD3IMWKHDtvzHAt_pt4wnZ06clgeO2Wbc980usnpsF7g8k9p81RcbS4JEZmuuA9NCmOmbyADXwgA9_-Aw"}"#.to_string(), + token_x_jwks_uri: "http://localhost:8080/tokenx/jwks".to_string(), + token_x_issuer: "http://localhost:8080/tokenx".to_string(), + token_x_token_endpoint: "http://localhost:8080/tokenx/token".to_string(), + } + } + + } } fn print_texas_logo() { @@ -92,7 +113,7 @@ async fn main() { let cfg = Config::parse(); - let state = setup_state(cfg.clone()).await; + let state = HandlerState::from_config(cfg.clone()).await.unwrap(); let app = router::new(state); let listener = tokio::net::TcpListener::bind(cfg.bind_address) @@ -103,59 +124,3 @@ async fn main() { axum::serve(listener, app).await.unwrap(); } - -async fn setup_state(cfg: Config) -> handlers::HandlerState { - // TODO: we should be able to conditionally enable certain providers based on the configuration - info!("Fetch JWKS for Maskinporten..."); - let maskinporten: Provider = Provider::new( - cfg.maskinporten_issuer.clone(), - cfg.maskinporten_client_id.clone(), - cfg.maskinporten_token_endpoint.clone(), - cfg.maskinporten_client_jwk.clone(), - jwks::Jwks::new(&cfg.maskinporten_issuer.clone(), &cfg.maskinporten_jwks_uri.clone()) - .await - .unwrap(), - ).unwrap(); - - // TODO: these two AAD providers should be a single provider, but we need to figure out how to handle the different token requests - info!("Fetch JWKS for Azure AD (on behalf of)..."); - let azure_ad_obo: Provider = Provider::new( - cfg.azure_ad_issuer.clone(), - cfg.azure_ad_client_id.clone(), - cfg.azure_ad_token_endpoint.clone(), - cfg.azure_ad_client_jwk.clone(), - jwks::Jwks::new(&cfg.azure_ad_issuer.clone(), &cfg.azure_ad_jwks_uri.clone()) - .await - .unwrap(), - ).unwrap(); - - info!("Fetch JWKS for Azure AD (client credentials)..."); - let azure_ad_cc: Provider = Provider::new( - cfg.azure_ad_issuer.clone(), - cfg.azure_ad_client_id.clone(), - cfg.azure_ad_token_endpoint.clone(), - cfg.azure_ad_client_jwk.clone(), - jwks::Jwks::new(&cfg.azure_ad_issuer.clone(), &cfg.azure_ad_jwks_uri.clone()) - .await - .unwrap(), - ).unwrap(); - - info!("Fetch JWKS for TokenX..."); - let token_x: Provider = Provider::new( - cfg.token_x_issuer.clone(), - cfg.token_x_client_id.clone(), - cfg.token_x_token_endpoint.clone(), - cfg.token_x_client_jwk.clone(), - jwks::Jwks::new(&cfg.token_x_issuer.clone(), &cfg.token_x_jwks_uri.clone()) - .await - .unwrap(), - ).unwrap(); - - handlers::HandlerState { - cfg: cfg.clone(), - maskinporten: Arc::new(RwLock::new(maskinporten)), - azure_ad_obo: Arc::new(RwLock::new(azure_ad_obo)), - azure_ad_cc: Arc::new(RwLock::new(azure_ad_cc)), - token_x: Arc::new(RwLock::new(token_x)), - } -} diff --git a/src/router.rs b/src/router.rs index a82f844..f5059e8 100644 --- a/src/router.rs +++ b/src/router.rs @@ -16,21 +16,32 @@ mod tests { use std::collections::HashMap; use crate::config::Config; use crate::types::{IdentityProvider, IntrospectRequest, TokenExchangeRequest, TokenRequest, TokenResponse}; - use crate::setup_state; use log::info; - use reqwest::Response; + use reqwest::{Error, Response}; use serde::Serialize; use serde_json::Value; - + use crate::handlers::HandlerState; // TODO: add some error case tests + /// Test a full round-trip of the `/token`, `/token/exchange`, and `/introspect` endpoints. + /// + /// Content-type encodings tested are both `application/json` and `application/x-www-form-urlencoded`. + /// + /// Test token exchange as follows: + /// 1. Request an initial token from the mock oauth2 server using the `/token` endpoint + /// 2. Exchange that token into a on-behalf-of token using Texas' `/token` endpoint + /// 3. Introspect the resulting token and check parameters + /// + /// Test client credentials as follows: + /// 1. Request a client credentials token using Texas' `/token` endpoint + /// 2. Introspect the resulting token and check parameters #[tokio::test] async fn test_roundtrip() { - let cfg = setup_config(); + let cfg = Config::mock(); let listener = tokio::net::TcpListener::bind(cfg.bind_address.clone()) .await .unwrap(); - let state = setup_state(cfg.clone()).await; + let state = HandlerState::from_config(cfg.clone()).await.unwrap(); let app = super::new(state); let address = listener.local_addr().unwrap(); info!("Serving on {:?}", address.clone()); @@ -38,34 +49,40 @@ mod tests { axum::serve(listener, app).await.unwrap(); }); - machine_to_machine_token(cfg.maskinporten_issuer.clone(),address.to_string(), IdentityProvider::Maskinporten).await; - machine_to_machine_token(cfg.azure_ad_issuer.clone(), address.to_string(), IdentityProvider::AzureAD).await; + for format in [RequestFormat::Form, RequestFormat::Json] { + machine_to_machine_token(cfg.maskinporten_issuer.clone(), address.to_string(), IdentityProvider::Maskinporten, format.clone()).await; + machine_to_machine_token(cfg.azure_ad_issuer.clone(), address.to_string(), IdentityProvider::AzureAD, format.clone()).await; - token_exchange_token(cfg.azure_ad_issuer.clone(), address.to_string(), IdentityProvider::AzureAD).await; - token_exchange_token(cfg.token_x_issuer.clone(), address.to_string(), IdentityProvider::TokenX).await; + token_exchange_token(cfg.azure_ad_issuer.clone(), address.to_string(), IdentityProvider::AzureAD, format.clone()).await; + token_exchange_token(cfg.token_x_issuer.clone(), address.to_string(), IdentityProvider::TokenX, format).await; + } join_handler.abort(); } - async fn machine_to_machine_token(expected_issuer: String, address: String, identity_provider: IdentityProvider) { - let params = TokenRequest { - target: "mytarget".to_string(), - identity_provider, - }; - - let response = do_post(format!("http://{}/token", address.clone().to_string()), params).await; + async fn machine_to_machine_token(expected_issuer: String, address: String, identity_provider: IdentityProvider, request_format: RequestFormat) { + let response = post_request( + format!("http://{}/token", address.clone().to_string()), + TokenRequest { + target: "mytarget".to_string(), + identity_provider, + }, + request_format.clone(), + ).await.unwrap(); assert_eq!(response.status(), 200); let body: TokenResponse = response.json().await.unwrap(); - assert!(body.expires_in_seconds > 0); + assert!(body.expires_in_seconds > 0); assert!(!body.access_token.is_empty()); - let params = IntrospectRequest { - token: body.access_token.clone(), - }; - - let response = do_post(format!("http://{}/introspect", address.clone().to_string()), params).await; + let response = post_request( + format!("http://{}/introspect", address.clone().to_string()), + IntrospectRequest { + token: body.access_token.clone(), + }, + request_format, + ).await.unwrap(); assert_eq!(response.status(), 200); let body: HashMap = response.json().await.unwrap(); @@ -73,7 +90,7 @@ mod tests { assert_eq!(body["iss"], Value::String(expected_issuer.to_string())); } - async fn token_exchange_token(expected_issuer: String, address: String, identity_provider: IdentityProvider) { + async fn token_exchange_token(expected_issuer: String, address: String, identity_provider: IdentityProvider, request_format: RequestFormat) { #[derive(Serialize)] struct AuthorizeRequest { grant_type: String, @@ -82,40 +99,44 @@ mod tests { client_secret: String, } - let params = AuthorizeRequest { - grant_type: "authorization_code".to_string(), - code: "mycode".to_string(), - client_id: "myclientid".to_string(), - client_secret: "myclientsecret".to_string(), - }; + // This request goes directly to the mock oauth2 server, which only accepts form encoding + let user_token_response = post_request( + "http://localhost:8080/token".to_string(), + AuthorizeRequest { + grant_type: "authorization_code".to_string(), + code: "mycode".to_string(), + client_id: "myclientid".to_string(), + client_secret: "myclientsecret".to_string(), + }, + RequestFormat::Form, + ).await.unwrap(); - let user_token_response = do_post("http://localhost:8080/token".to_string(), params ).await; assert_eq!(user_token_response.status(), 200); let user_token: TokenResponse = user_token_response.json().await.unwrap(); - let params = TokenExchangeRequest { - target: "mytarget".to_string(), - identity_provider, - user_token: user_token.access_token, - }; + let response = post_request( + format!("http://{}/token/exchange", address.clone().to_string()), + TokenExchangeRequest { + target: "mytarget".to_string(), + identity_provider, + user_token: user_token.access_token, + }, + request_format.clone() + ).await.unwrap(); - let response = do_post(format!("http://{}/token/exchange", address.clone().to_string()), params).await; - - if response.status() != 200 { - let body = response.text().await.unwrap(); - panic!("Failed to exchange token: {:?}", body); - } - assert_eq!(response.status(), 200); + assert_eq!(response.status(), 200, "failed to exchange token: {:?}", response.text().await.unwrap()); let body: TokenResponse = response.json().await.unwrap(); - assert!(body.expires_in_seconds > 0); + assert!(body.expires_in_seconds > 0); assert!(!body.access_token.is_empty()); - let params = IntrospectRequest { - token: body.access_token.clone(), - }; - - let response = do_post(format!("http://{}/introspect", address.clone().to_string()), params).await; + let response = post_request( + format!("http://{}/introspect", address.clone().to_string()), + IntrospectRequest { + token: body.access_token.clone(), + }, + request_format + ).await.unwrap(); assert_eq!(response.status(), 200); let body: HashMap = response.json().await.unwrap(); @@ -124,35 +145,23 @@ mod tests { assert!(!body["sub"].to_string().is_empty()); } - fn setup_config() -> Config { - Config { - bind_address: "127.0.0.1:0".to_string(), - maskinporten_client_id: "client-id".to_string(), - maskinporten_client_jwk: r#"{"p":"_LNnIjBshCrFuxtjUC2KKzg_NTVv26UZh5j12_9r5mYTxb8yW047jOYFEGvIdMkTRLGOBig6fLWzgd62lnLainzV35J6K6zr4jQfTldLondlkldMR6nQrp1KfnNUuRbKvzpNKkhl12-f1l91l0tCx3s4blztvWgdzN2xBfvWV68","kty":"RSA","q":"9MIWsbIA3WjiR_Ful5FM8NCgb6JdS2D6ySHVepoNI-iAPilcltF_J2orjfLqAxeztTskPi45wtF_-eV4GIYSzvMo-gFiXLMrvEa7WaWizMi_7Bu9tEk3m_f3IDLN9lwULYoebkDbiXx6GOiuj0VkuKz8ckYFNKLCMP9QRLFff-0","d":"J6UX848X8tNz-09PFvcFDUVqak32GXzoPjnuDjBsxNUvG7LxenLmM_i8tvYl0EW9Ztn4AiCqJUoHw5cX3jz_mSqGl7ciaDedpKm_AetcZwHiEuT1EpSKRPMmOMQSqcJqXrdbbWB8gdUrnTKZIlJCfj7yqgT16ypC43TnwjA0UwxhG5pHaYjKI3pPdoHg2BzA-iubHjVn15Sz7-pnjBmeGDbEFa7ADY-1yPHCmqqvPKTNhoCNW6RpG34Id9hXslPa3X-7pAhJrDBd0_NPlktSA2rUkifYiZURhHR5ijhe0v3uw6kYP8f_foVm_C8O1ExkxXh9Dg8KDZ89dbsSOtBc0Q","e":"AQAB","use":"sig","kid":"l7C_WJgbZ_6e59vPrFETAehX7Dsp7fIyvSV4XhotsGs","qi":"cQFN5q5WhYkzgd1RS0rGqvpX1AkmZMrLv2MW04gSfu0dDwpbsSAu8EUCQW9oA4pr6V7R9CBSu9kdN2iY5SR-hZvEad5nDKPV1F3TMQYv5KpRiS_0XhfV5PcolUJVO_4p3h8d-mo2hh1Sw2fairAKOzvnwJCQ6DFkiY7H1cqwA54","dp":"YTql9AGtvyy158gh7jeXcgmySEbHQzvDFulDr-IXIg8kjHGEbp0rTIs0Z50RA95aC5RFkRjpaBKBfvaySjDm5WIi6GLzntpp6B8l7H6qG1jVO_la4Df2kzjx8LVvY8fhOrKz_hDdHodUeKdCF3RdvWMr00ruLnJhBPJHqoW7cwE","alg":"RS256","dq":"IZA4AngRbEtEtG7kJn6zWVaSmZxfRMXwvgIYvy4-3Qy2AVA0tS3XTPVfMaD8_B2U9CY_CxPVseR-sysHc_12uNBZbycfcOzU84WTjXCMSZ7BysPnGMDtkkLHra-p1L29upz1HVNhh5H9QEswHM98R2LZX2ZAsn4bORLZ1AGqweU","n":"8ZqUp5Cs90XpNn8tJBdUUxdGH4bjqKjFj8lyB3x50RpTuECuwzX1NpVqyFENDiEtMja5fdmJl6SErjnhj6kbhcmfmFibANuG-0WlV5yMysdSbocd75C1JQbiPdpHdXrijmVFMfDnoZTQ-ErNsqqngTNkn5SXBcPenli6Cf9MTSchZuh_qFj_B7Fp3CWKehTiyBcLlNOIjYsXX8WQjZkWKGpQ23AWjZulngWRektLcRWuEKTWaRBtbAr3XAfSmcqTICrebaD3IMWKHDtvzHAt_pt4wnZ06clgeO2Wbc980usnpsF7g8k9p81RcbS4JEZmuuA9NCmOmbyADXwgA9_-Aw"}"#.to_string(), - maskinporten_jwks_uri: "http://localhost:8080/maskinporten/jwks".to_string(), - maskinporten_issuer: "http://localhost:8080/maskinporten".to_string(), - maskinporten_token_endpoint: "http://localhost:8080/maskinporten/token".to_string(), - azure_ad_client_id: "client-id".to_string(), - azure_ad_client_jwk: r#"{"p":"_LNnIjBshCrFuxtjUC2KKzg_NTVv26UZh5j12_9r5mYTxb8yW047jOYFEGvIdMkTRLGOBig6fLWzgd62lnLainzV35J6K6zr4jQfTldLondlkldMR6nQrp1KfnNUuRbKvzpNKkhl12-f1l91l0tCx3s4blztvWgdzN2xBfvWV68","kty":"RSA","q":"9MIWsbIA3WjiR_Ful5FM8NCgb6JdS2D6ySHVepoNI-iAPilcltF_J2orjfLqAxeztTskPi45wtF_-eV4GIYSzvMo-gFiXLMrvEa7WaWizMi_7Bu9tEk3m_f3IDLN9lwULYoebkDbiXx6GOiuj0VkuKz8ckYFNKLCMP9QRLFff-0","d":"J6UX848X8tNz-09PFvcFDUVqak32GXzoPjnuDjBsxNUvG7LxenLmM_i8tvYl0EW9Ztn4AiCqJUoHw5cX3jz_mSqGl7ciaDedpKm_AetcZwHiEuT1EpSKRPMmOMQSqcJqXrdbbWB8gdUrnTKZIlJCfj7yqgT16ypC43TnwjA0UwxhG5pHaYjKI3pPdoHg2BzA-iubHjVn15Sz7-pnjBmeGDbEFa7ADY-1yPHCmqqvPKTNhoCNW6RpG34Id9hXslPa3X-7pAhJrDBd0_NPlktSA2rUkifYiZURhHR5ijhe0v3uw6kYP8f_foVm_C8O1ExkxXh9Dg8KDZ89dbsSOtBc0Q","e":"AQAB","use":"sig","kid":"l7C_WJgbZ_6e59vPrFETAehX7Dsp7fIyvSV4XhotsGs","qi":"cQFN5q5WhYkzgd1RS0rGqvpX1AkmZMrLv2MW04gSfu0dDwpbsSAu8EUCQW9oA4pr6V7R9CBSu9kdN2iY5SR-hZvEad5nDKPV1F3TMQYv5KpRiS_0XhfV5PcolUJVO_4p3h8d-mo2hh1Sw2fairAKOzvnwJCQ6DFkiY7H1cqwA54","dp":"YTql9AGtvyy158gh7jeXcgmySEbHQzvDFulDr-IXIg8kjHGEbp0rTIs0Z50RA95aC5RFkRjpaBKBfvaySjDm5WIi6GLzntpp6B8l7H6qG1jVO_la4Df2kzjx8LVvY8fhOrKz_hDdHodUeKdCF3RdvWMr00ruLnJhBPJHqoW7cwE","alg":"RS256","dq":"IZA4AngRbEtEtG7kJn6zWVaSmZxfRMXwvgIYvy4-3Qy2AVA0tS3XTPVfMaD8_B2U9CY_CxPVseR-sysHc_12uNBZbycfcOzU84WTjXCMSZ7BysPnGMDtkkLHra-p1L29upz1HVNhh5H9QEswHM98R2LZX2ZAsn4bORLZ1AGqweU","n":"8ZqUp5Cs90XpNn8tJBdUUxdGH4bjqKjFj8lyB3x50RpTuECuwzX1NpVqyFENDiEtMja5fdmJl6SErjnhj6kbhcmfmFibANuG-0WlV5yMysdSbocd75C1JQbiPdpHdXrijmVFMfDnoZTQ-ErNsqqngTNkn5SXBcPenli6Cf9MTSchZuh_qFj_B7Fp3CWKehTiyBcLlNOIjYsXX8WQjZkWKGpQ23AWjZulngWRektLcRWuEKTWaRBtbAr3XAfSmcqTICrebaD3IMWKHDtvzHAt_pt4wnZ06clgeO2Wbc980usnpsF7g8k9p81RcbS4JEZmuuA9NCmOmbyADXwgA9_-Aw"}"#.to_string(), - azure_ad_jwks_uri: "http://localhost:8080/azuread/jwks".to_string(), - azure_ad_issuer: "http://localhost:8080/azuread".to_string(), - azure_ad_token_endpoint: "http://localhost:8080/azuread/token".to_string(), - token_x_client_id: "client-id".to_string(), - token_x_client_jwk: r#"{"p":"_LNnIjBshCrFuxtjUC2KKzg_NTVv26UZh5j12_9r5mYTxb8yW047jOYFEGvIdMkTRLGOBig6fLWzgd62lnLainzV35J6K6zr4jQfTldLondlkldMR6nQrp1KfnNUuRbKvzpNKkhl12-f1l91l0tCx3s4blztvWgdzN2xBfvWV68","kty":"RSA","q":"9MIWsbIA3WjiR_Ful5FM8NCgb6JdS2D6ySHVepoNI-iAPilcltF_J2orjfLqAxeztTskPi45wtF_-eV4GIYSzvMo-gFiXLMrvEa7WaWizMi_7Bu9tEk3m_f3IDLN9lwULYoebkDbiXx6GOiuj0VkuKz8ckYFNKLCMP9QRLFff-0","d":"J6UX848X8tNz-09PFvcFDUVqak32GXzoPjnuDjBsxNUvG7LxenLmM_i8tvYl0EW9Ztn4AiCqJUoHw5cX3jz_mSqGl7ciaDedpKm_AetcZwHiEuT1EpSKRPMmOMQSqcJqXrdbbWB8gdUrnTKZIlJCfj7yqgT16ypC43TnwjA0UwxhG5pHaYjKI3pPdoHg2BzA-iubHjVn15Sz7-pnjBmeGDbEFa7ADY-1yPHCmqqvPKTNhoCNW6RpG34Id9hXslPa3X-7pAhJrDBd0_NPlktSA2rUkifYiZURhHR5ijhe0v3uw6kYP8f_foVm_C8O1ExkxXh9Dg8KDZ89dbsSOtBc0Q","e":"AQAB","use":"sig","kid":"l7C_WJgbZ_6e59vPrFETAehX7Dsp7fIyvSV4XhotsGs","qi":"cQFN5q5WhYkzgd1RS0rGqvpX1AkmZMrLv2MW04gSfu0dDwpbsSAu8EUCQW9oA4pr6V7R9CBSu9kdN2iY5SR-hZvEad5nDKPV1F3TMQYv5KpRiS_0XhfV5PcolUJVO_4p3h8d-mo2hh1Sw2fairAKOzvnwJCQ6DFkiY7H1cqwA54","dp":"YTql9AGtvyy158gh7jeXcgmySEbHQzvDFulDr-IXIg8kjHGEbp0rTIs0Z50RA95aC5RFkRjpaBKBfvaySjDm5WIi6GLzntpp6B8l7H6qG1jVO_la4Df2kzjx8LVvY8fhOrKz_hDdHodUeKdCF3RdvWMr00ruLnJhBPJHqoW7cwE","alg":"RS256","dq":"IZA4AngRbEtEtG7kJn6zWVaSmZxfRMXwvgIYvy4-3Qy2AVA0tS3XTPVfMaD8_B2U9CY_CxPVseR-sysHc_12uNBZbycfcOzU84WTjXCMSZ7BysPnGMDtkkLHra-p1L29upz1HVNhh5H9QEswHM98R2LZX2ZAsn4bORLZ1AGqweU","n":"8ZqUp5Cs90XpNn8tJBdUUxdGH4bjqKjFj8lyB3x50RpTuECuwzX1NpVqyFENDiEtMja5fdmJl6SErjnhj6kbhcmfmFibANuG-0WlV5yMysdSbocd75C1JQbiPdpHdXrijmVFMfDnoZTQ-ErNsqqngTNkn5SXBcPenli6Cf9MTSchZuh_qFj_B7Fp3CWKehTiyBcLlNOIjYsXX8WQjZkWKGpQ23AWjZulngWRektLcRWuEKTWaRBtbAr3XAfSmcqTICrebaD3IMWKHDtvzHAt_pt4wnZ06clgeO2Wbc980usnpsF7g8k9p81RcbS4JEZmuuA9NCmOmbyADXwgA9_-Aw"}"#.to_string(), - token_x_jwks_uri: "http://localhost:8080/tokenx/jwks".to_string(), - token_x_issuer: "http://localhost:8080/tokenx".to_string(), - token_x_token_endpoint: "http://localhost:8080/tokenx/token".to_string(), - } + #[derive(Clone)] + enum RequestFormat { + Json, + Form, } - async fn do_post(url: String, params: impl Serialize) -> Response { + async fn post_request(url: String, params: impl Serialize, format: RequestFormat) -> Result { let client = reqwest::Client::new(); - client + let request = client .post(url) - .header("accept", "application/json") - .form(¶ms) + .header("accept", "application/json"); + let request = match format { + RequestFormat::Json => request.json(¶ms), + RequestFormat::Form => request.form(¶ms), + }; + request .send() .await - .unwrap() } }