feat: add token introspection on sample /user/home endpoint and inject access_token into client cookie
This commit is contained in:
		
							
								
								
									
										12
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							@@ -165,6 +165,7 @@ dependencies = [
 | 
			
		||||
 "axum",
 | 
			
		||||
 "axum-core",
 | 
			
		||||
 "bytes",
 | 
			
		||||
 "cookie",
 | 
			
		||||
 "futures-util",
 | 
			
		||||
 "http 1.1.0",
 | 
			
		||||
 "http-body 1.0.1",
 | 
			
		||||
@@ -372,6 +373,17 @@ dependencies = [
 | 
			
		||||
 "unicode-segmentation",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "cookie"
 | 
			
		||||
version = "0.18.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "percent-encoding",
 | 
			
		||||
 "time",
 | 
			
		||||
 "version_check",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "core-foundation"
 | 
			
		||||
version = "0.9.4"
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ edition = "2021"
 | 
			
		||||
askama = "0.12.1"
 | 
			
		||||
axum = { version = "0.7.5", features = ["tracing", "multipart", "macros", "http2"] }
 | 
			
		||||
axum-core = { version = "0.4.3", features = ["tracing"] }
 | 
			
		||||
axum-extra = { version = "0.9.3", features = ["form"] }
 | 
			
		||||
axum-extra = { version = "0.9.3", features = ["cookie", "form"] }
 | 
			
		||||
config = "0.14.0"
 | 
			
		||||
http = "1.1.0"
 | 
			
		||||
openidconnect = "3.5.0"
 | 
			
		||||
 
 | 
			
		||||
@@ -11,3 +11,4 @@ oidc:
 | 
			
		||||
    client_id: client-id-you-generated
 | 
			
		||||
    client_secret: client-secret-that-you-generated-or-copied
 | 
			
		||||
    callback: https://the-public-domain-of-this.service.tld/openid/callback
 | 
			
		||||
    introspection_url: [...]
 | 
			
		||||
 
 | 
			
		||||
@@ -27,6 +27,7 @@ pub struct OidcConfig {
 | 
			
		||||
	pub client_id: String,
 | 
			
		||||
	pub client_secret: String,
 | 
			
		||||
	pub callback: Url,
 | 
			
		||||
	pub introspection_url: Url,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, Deserialize, Clone)]
 | 
			
		||||
 
 | 
			
		||||
@@ -1 +1,2 @@
 | 
			
		||||
pub mod openid;
 | 
			
		||||
pub mod powerdns;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										34
									
								
								src/error/openid.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/error/openid.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
			
		||||
use std::fmt::Display;
 | 
			
		||||
 | 
			
		||||
use axum::response::IntoResponse;
 | 
			
		||||
use reqwest::StatusCode;
 | 
			
		||||
 | 
			
		||||
#[derive(Debug)]
 | 
			
		||||
pub enum AuthError {
 | 
			
		||||
	OpenIdConfig(openidconnect::ConfigurationError),
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Display for AuthError {
 | 
			
		||||
	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 | 
			
		||||
		match self {
 | 
			
		||||
			AuthError::OpenIdConfig(e) => f.write_fmt(format_args!("OpenID Connect configuration error: {:?}", e)),
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl std::error::Error for AuthError {}
 | 
			
		||||
 | 
			
		||||
impl From<openidconnect::ConfigurationError> for AuthError {
 | 
			
		||||
	fn from(e: openidconnect::ConfigurationError) -> Self {
 | 
			
		||||
		Self::OpenIdConfig(e)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl IntoResponse for AuthError {
 | 
			
		||||
	fn into_response(self) -> axum::response::Response {
 | 
			
		||||
		(
 | 
			
		||||
			StatusCode::INTERNAL_SERVER_ERROR,
 | 
			
		||||
			format!("Authentication error: {}", self),
 | 
			
		||||
		).into_response()
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
use axum::response::{
 | 
			
		||||
	IntoResponse,
 | 
			
		||||
	Redirect,
 | 
			
		||||
	Response,
 | 
			
		||||
};
 | 
			
		||||
use axum::extract::{
 | 
			
		||||
	State,
 | 
			
		||||
@@ -47,19 +48,19 @@ pub async fn callback(
 | 
			
		||||
	State(app_state): State<AppState>,
 | 
			
		||||
	Query(callback): Query<OidcCallback>,
 | 
			
		||||
) -> impl IntoResponse {
 | 
			
		||||
	println!("Received auth code: {}", callback.code.clone());
 | 
			
		||||
	let token_response = app_state.oidc_client
 | 
			
		||||
		.exchange_code(AuthorizationCode::new(callback.code.to_owned()))
 | 
			
		||||
		.request_async(async_http_client)
 | 
			
		||||
		.await
 | 
			
		||||
		.unwrap();
 | 
			
		||||
	println!("Exchanged for access token: {}", token_response.access_token().secret());
 | 
			
		||||
	println!("\tvalid for scopes {:?}", token_response.scopes());
 | 
			
		||||
	Redirect::to("/user/home")
 | 
			
		||||
//	let id_token_verifier: CoreIdTokenVerifier = app_state.oidc_client.id_token_verifier();
 | 
			
		||||
//	let id_token_claims: &CoreIdTokenClaims = token_response
 | 
			
		||||
//		.extra_fields()
 | 
			
		||||
//		.id_token()
 | 
			
		||||
//		.expect("IdP did not return an ID token")
 | 
			
		||||
//		.claims(&id_token_verifier, &nonce)
 | 
			
		||||
	Response::builder()
 | 
			
		||||
		.status(200)
 | 
			
		||||
		// TODO: set 'Secure' flag when served over https
 | 
			
		||||
		.header("Set-Cookie", format!(
 | 
			
		||||
			"access_token={}; SameSite=Strict; HttpOnly; Path=/;",
 | 
			
		||||
			token_response.access_token().secret(),
 | 
			
		||||
		))
 | 
			
		||||
		.header("Content-Type", "text/html")
 | 
			
		||||
		.body("Click here to go to your <a href=\"/user/home\">User Home</a>".to_string())
 | 
			
		||||
		.unwrap()
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,48 @@
 | 
			
		||||
use std::time::{
 | 
			
		||||
	Duration,
 | 
			
		||||
	Instant,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
use askama::Template;
 | 
			
		||||
use axum::response::IntoResponse;
 | 
			
		||||
use axum::extract::State;
 | 
			
		||||
use axum_extra::extract::cookie::CookieJar;
 | 
			
		||||
 | 
			
		||||
use openidconnect::{
 | 
			
		||||
	AccessToken,
 | 
			
		||||
	reqwest::async_http_client,
 | 
			
		||||
	TokenIntrospectionResponse,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
use crate::util::askama::HtmlTemplate;
 | 
			
		||||
use crate::AppState;
 | 
			
		||||
use crate::error::openid::AuthError;
 | 
			
		||||
 | 
			
		||||
#[derive(Template)]
 | 
			
		||||
#[template(path = "user_home.html")]
 | 
			
		||||
struct UserHomeTemplate {}
 | 
			
		||||
 | 
			
		||||
pub async fn home() -> impl IntoResponse {
 | 
			
		||||
	HtmlTemplate(UserHomeTemplate {})
 | 
			
		||||
struct UserHomeTemplate {
 | 
			
		||||
	is_active: bool,
 | 
			
		||||
	username: String,
 | 
			
		||||
	duration: Duration,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub async fn home(
 | 
			
		||||
	State(app_state): State<AppState>,
 | 
			
		||||
	cookies: CookieJar
 | 
			
		||||
) -> Result<impl IntoResponse, AuthError> {
 | 
			
		||||
	let now = Instant::now();
 | 
			
		||||
	let token_serialized: Option<String> = cookies.get("access_token")
 | 
			
		||||
		.map(|cookie| cookie.value().to_owned());
 | 
			
		||||
	let (is_active, username);
 | 
			
		||||
	(is_active, username) = match token_serialized {
 | 
			
		||||
		Some(token) => {
 | 
			
		||||
			let introspection_response = app_state.oidc_client
 | 
			
		||||
				.introspect(&AccessToken::new(token))?
 | 
			
		||||
				.request_async(async_http_client).await.unwrap();
 | 
			
		||||
			println!("Token introspected, answer is {:?}", introspection_response);
 | 
			
		||||
			(introspection_response.active(), introspection_response.username().unwrap_or("").to_string())
 | 
			
		||||
		},
 | 
			
		||||
		None => (false, "".to_string())
 | 
			
		||||
	};
 | 
			
		||||
	Ok(HtmlTemplate(UserHomeTemplate { is_active, username, duration: now.elapsed() }))
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,10 @@ use tokio::sync::Mutex;
 | 
			
		||||
 | 
			
		||||
use axum::Router;
 | 
			
		||||
use axum::routing::{get, post};
 | 
			
		||||
use openidconnect::core::CoreClient;
 | 
			
		||||
use openidconnect::{
 | 
			
		||||
	IntrospectionUrl,
 | 
			
		||||
	core::CoreClient,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
pub mod model;
 | 
			
		||||
mod config;
 | 
			
		||||
@@ -32,7 +35,8 @@ async fn main() {
 | 
			
		||||
		config.oidc.client_id,
 | 
			
		||||
		config.oidc.client_secret,
 | 
			
		||||
		config.oidc.callback,
 | 
			
		||||
	).await;
 | 
			
		||||
	).await
 | 
			
		||||
	.set_introspection_uri(IntrospectionUrl::from_url(config.oidc.introspection_url));
 | 
			
		||||
 | 
			
		||||
	let pdns_client = util::powerdns::PowerDnsApi::new(
 | 
			
		||||
		config.powerdns.server_url,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,13 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<h1> User Home</h1>
 | 
			
		||||
{% if username != "" %}
 | 
			
		||||
<h1>Hello <i>{{ username }}</i></h1>
 | 
			
		||||
{% else %}
 | 
			
		||||
<h1>User Home</h1>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
<p>Your user session is <b>{% if is_active %}active{% else %}inactive{% endif %}</b></p>
 | 
			
		||||
 | 
			
		||||
<p>Request took <b>{{ duration.as_millis() }}</b>ms</p>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user