feat: add token introspection on sample /user/home endpoint and inject access_token into client cookie

This commit is contained in:
transcaffeine 2024-09-08 20:47:11 +02:00
parent 436dbe1393
commit 2fc6caad1c
Signed by: transcaffeine
GPG Key ID: 03624C433676E465
10 changed files with 116 additions and 18 deletions

12
Cargo.lock generated
View File

@ -165,6 +165,7 @@ dependencies = [
"axum", "axum",
"axum-core", "axum-core",
"bytes", "bytes",
"cookie",
"futures-util", "futures-util",
"http 1.1.0", "http 1.1.0",
"http-body 1.0.1", "http-body 1.0.1",
@ -372,6 +373,17 @@ dependencies = [
"unicode-segmentation", "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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"

View File

@ -9,7 +9,7 @@ edition = "2021"
askama = "0.12.1" askama = "0.12.1"
axum = { version = "0.7.5", features = ["tracing", "multipart", "macros", "http2"] } axum = { version = "0.7.5", features = ["tracing", "multipart", "macros", "http2"] }
axum-core = { version = "0.4.3", features = ["tracing"] } 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" config = "0.14.0"
http = "1.1.0" http = "1.1.0"
openidconnect = "3.5.0" openidconnect = "3.5.0"

View File

@ -11,3 +11,4 @@ oidc:
client_id: client-id-you-generated client_id: client-id-you-generated
client_secret: client-secret-that-you-generated-or-copied client_secret: client-secret-that-you-generated-or-copied
callback: https://the-public-domain-of-this.service.tld/openid/callback callback: https://the-public-domain-of-this.service.tld/openid/callback
introspection_url: [...]

View File

@ -27,6 +27,7 @@ pub struct OidcConfig {
pub client_id: String, pub client_id: String,
pub client_secret: String, pub client_secret: String,
pub callback: Url, pub callback: Url,
pub introspection_url: Url,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]

View File

@ -1 +1,2 @@
pub mod openid;
pub mod powerdns; pub mod powerdns;

34
src/error/openid.rs Normal file
View 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()
}
}

View File

@ -1,6 +1,7 @@
use axum::response::{ use axum::response::{
IntoResponse, IntoResponse,
Redirect, Redirect,
Response,
}; };
use axum::extract::{ use axum::extract::{
State, State,
@ -47,19 +48,19 @@ pub async fn callback(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Query(callback): Query<OidcCallback>, Query(callback): Query<OidcCallback>,
) -> impl IntoResponse { ) -> impl IntoResponse {
println!("Received auth code: {}", callback.code.clone());
let token_response = app_state.oidc_client let token_response = app_state.oidc_client
.exchange_code(AuthorizationCode::new(callback.code.to_owned())) .exchange_code(AuthorizationCode::new(callback.code.to_owned()))
.request_async(async_http_client) .request_async(async_http_client)
.await .await
.unwrap(); .unwrap();
println!("Exchanged for access token: {}", token_response.access_token().secret()); Response::builder()
println!("\tvalid for scopes {:?}", token_response.scopes()); .status(200)
Redirect::to("/user/home") // TODO: set 'Secure' flag when served over https
// let id_token_verifier: CoreIdTokenVerifier = app_state.oidc_client.id_token_verifier(); .header("Set-Cookie", format!(
// let id_token_claims: &CoreIdTokenClaims = token_response "access_token={}; SameSite=Strict; HttpOnly; Path=/;",
// .extra_fields() token_response.access_token().secret(),
// .id_token() ))
// .expect("IdP did not return an ID token") .header("Content-Type", "text/html")
// .claims(&id_token_verifier, &nonce) .body("Click here to go to your <a href=\"/user/home\">User Home</a>".to_string())
.unwrap()
} }

View File

@ -1,12 +1,48 @@
use std::time::{
Duration,
Instant,
};
use askama::Template; use askama::Template;
use axum::response::IntoResponse; 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::util::askama::HtmlTemplate;
use crate::AppState;
use crate::error::openid::AuthError;
#[derive(Template)] #[derive(Template)]
#[template(path = "user_home.html")] #[template(path = "user_home.html")]
struct UserHomeTemplate {} struct UserHomeTemplate {
is_active: bool,
pub async fn home() -> impl IntoResponse { username: String,
HtmlTemplate(UserHomeTemplate {}) 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() }))
} }

View File

@ -5,7 +5,10 @@ use tokio::sync::Mutex;
use axum::Router; use axum::Router;
use axum::routing::{get, post}; use axum::routing::{get, post};
use openidconnect::core::CoreClient; use openidconnect::{
IntrospectionUrl,
core::CoreClient,
};
pub mod model; pub mod model;
mod config; mod config;
@ -32,7 +35,8 @@ async fn main() {
config.oidc.client_id, config.oidc.client_id,
config.oidc.client_secret, config.oidc.client_secret,
config.oidc.callback, config.oidc.callback,
).await; ).await
.set_introspection_uri(IntrospectionUrl::from_url(config.oidc.introspection_url));
let pdns_client = util::powerdns::PowerDnsApi::new( let pdns_client = util::powerdns::PowerDnsApi::new(
config.powerdns.server_url, config.powerdns.server_url,

View File

@ -1,5 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
{% if username != "" %}
<h1>Hello <i>{{ username }}</i></h1>
{% else %}
<h1>User Home</h1> <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 %} {% endblock content %}