From 2700978df4d419b3a6fb2ec67366bd49520ef5c7 Mon Sep 17 00:00:00 2001 From: Alexander Korolev Date: Sun, 12 Jan 2025 00:33:10 +0100 Subject: [PATCH] Release v0.16.1 --- Cargo.toml | 2 +- README.md | 313 ++++++++++++++++++++++++++++++++++++++++++++++++++++- release.sh | 23 ++-- 3 files changed, 325 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 48d2e6e..c616571 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openid" -version = "0.16.0" +version = "0.16.1" authors = ["Alexander Korolev "] edition = "2021" categories = ["authentication"] diff --git a/README.md b/README.md index 26a656e..4c9d547 100644 --- a/README.md +++ b/README.md @@ -59,13 +59,322 @@ This example provides only Rust part, assuming just default JHipster frontend se in Cargo.toml: ```toml -404: Not Found +[dependencies] +anyhow = "1.0" +cookie = "0.18" +dotenv = "0.15" +log = "0.4" +openid = "0.16" +pretty_env_logger = "0.5" +reqwest = "0.12" +serde = { version = "1", default-features = false, features = [ "derive" ] } +serde_json = "1" +tokio = { version = "1", default-features = false, features = [ "rt-multi-thread", "macros" ] } +uuid = { version = "1.0", default-features = false, features = [ "v4" ] } +warp = { version = "0.3", default-features = false } ``` in src/main.rs: ```rust, compile_fail -404: Not Found +use std::{convert::Infallible, env, net::SocketAddr, sync::Arc}; + +use cookie::time::Duration; +use log::{error, info}; +use openid::{Client, Discovered, DiscoveredClient, Options, StandardClaims, Token, Userinfo}; +use openid_examples::{ + entity::{LoginQuery, Sessions, User}, + INDEX_HTML, +}; +use tokio::sync::RwLock; +use warp::{ + http::{Response, StatusCode}, + reject, Filter, Rejection, Reply, +}; + +type OpenIDClient = Client; + +const EXAMPLE_COOKIE: &str = "openid_warp_example"; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenv::dotenv().ok(); + + pretty_env_logger::init(); + + let client_id = env::var("CLIENT_ID").expect(" for your provider"); + let client_secret = env::var("CLIENT_SECRET").ok(); + let issuer_url = + env::var("ISSUER").unwrap_or_else(|_| "https://accounts.google.com".to_string()); + let redirect = Some(host("/login/oauth2/code/oidc")); + let issuer = reqwest::Url::parse(&issuer_url)?; + let listen: SocketAddr = env::var("LISTEN") + .unwrap_or_else(|_| "127.0.0.1:8080".to_string()) + .parse()?; + + info!("redirect: {:?}", redirect); + info!("issuer: {}", issuer); + + let client = Arc::new( + DiscoveredClient::discover( + client_id, + client_secret.unwrap_or_default(), + redirect, + issuer, + ) + .await?, + ); + + info!("discovered config: {:?}", client.config()); + + let with_client = |client: Arc>| warp::any().map(move || client.clone()); + + let sessions = Arc::new(RwLock::new(Sessions::default())); + + let with_sessions = |sessions: Arc>| warp::any().map(move || sessions.clone()); + + let index = warp::path::end() + .and(warp::get()) + .map(|| warp::reply::html(INDEX_HTML)); + + let authorize = warp::path!("oauth2" / "authorization" / "oidc") + .and(warp::get()) + .and(with_client(client.clone())) + .and_then(reply_authorize); + + let login = warp::path!("login" / "oauth2" / "code" / "oidc") + .and(warp::get()) + .and(with_client(client.clone())) + .and(warp::query::()) + .and(with_sessions(sessions.clone())) + .and_then(reply_login); + + let logout = warp::path!("logout") + .and(warp::get()) + .and(with_client(client.clone())) + .and(warp::cookie::optional(EXAMPLE_COOKIE)) + .and(with_sessions(sessions.clone())) + .and_then(reply_logout); + + let api_account = warp::path!("api" / "account") + .and(warp::get()) + .and(with_user(sessions)) + .map(|user: User| warp::reply::json(&user)); + + let routes = index + .or(authorize) + .or(login) + .or(logout) + .or(api_account) + .recover(handle_rejections); + + let logged_routes = routes.with(warp::log("openid_warp_example")); + + warp::serve(logged_routes).run(listen).await; + + Ok(()) +} + +async fn request_token( + oidc_client: &OpenIDClient, + login_query: &LoginQuery, +) -> anyhow::Result> { + let mut token: Token = oidc_client.request_token(&login_query.code).await?.into(); + + if let Some(id_token) = token.id_token.as_mut() { + oidc_client.decode_token(id_token)?; + oidc_client.validate_token(id_token, None, None)?; + info!("token: {:?}", id_token); + } else { + return Ok(None); + } + + let userinfo = oidc_client.request_userinfo(&token).await?; + + info!("user info: {:?}", userinfo); + + Ok(Some((token, userinfo))) +} + +async fn reply_login( + oidc_client: Arc, + login_query: LoginQuery, + sessions: Arc>, +) -> Result { + let request_token = request_token(&oidc_client, &login_query).await; + match request_token { + Ok(Some((token, user_info))) => { + let id = uuid::Uuid::new_v4().to_string(); + + let login = user_info.preferred_username.clone(); + let email = user_info.email.clone(); + + let user = User { + id: user_info.sub.clone().unwrap_or_default(), + login, + last_name: user_info.family_name.clone(), + first_name: user_info.name.clone(), + email, + activated: user_info.email_verified, + image_url: user_info.picture.clone().map(|x| x.to_string()), + lang_key: Some("en".to_string()), + authorities: vec!["ROLE_USER".to_string()], + }; + + let authorization_cookie = ::cookie::Cookie::build((EXAMPLE_COOKIE, &id)) + .path("/") + .http_only(true) + .build() + .to_string(); + + sessions + .write() + .await + .map + .insert(id, (user, token, user_info)); + + let redirect_url = login_query.state.clone().unwrap_or_else(|| host("/")); + + Ok(Response::builder() + .status(StatusCode::MOVED_PERMANENTLY) + .header(warp::http::header::LOCATION, redirect_url) + .header(warp::http::header::SET_COOKIE, authorization_cookie) + .body("") + .unwrap()) + } + Ok(None) => { + error!("login error in call: no id_token found"); + + response_unauthorized() + } + Err(err) => { + error!("login error in call: {:?}", err); + + response_unauthorized() + } + } +} + +fn response_unauthorized() -> Result, Infallible> { + Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body("") + .unwrap()) +} + +async fn reply_logout( + oidc_client: Arc, + session_id: Option, + sessions: Arc>, +) -> Result { + let Some(id) = session_id else { + return response_unauthorized(); + }; + + let session_removed = sessions.write().await.map.remove(&id); + + if let Some(id_token) = session_removed.and_then(|(_, token, _)| token.bearer.id_token) { + let authorization_cookie = ::cookie::Cookie::build((EXAMPLE_COOKIE, &id)) + .path("/") + .http_only(true) + .max_age(Duration::seconds(-1)) + .build() + .to_string(); + + let return_redirect_url = host("/"); + + let redirect_url = oidc_client + .config() + .end_session_endpoint + .clone() + .map(|mut logout_provider_endpoint| { + logout_provider_endpoint + .query_pairs_mut() + .append_pair("id_token_hint", &id_token) + .append_pair("post_logout_redirect_uri", &return_redirect_url); + logout_provider_endpoint.to_string() + }) + .unwrap_or_else(|| return_redirect_url); + + info!("logout redirect url: {redirect_url}"); + + Ok(Response::builder() + .status(StatusCode::FOUND) + .header(warp::http::header::LOCATION, redirect_url) + .header(warp::http::header::SET_COOKIE, authorization_cookie) + .body("") + .unwrap()) + } else { + response_unauthorized() + } +} + +async fn reply_authorize(oidc_client: Arc) -> Result { + let origin_url = env::var("ORIGIN").unwrap_or_else(|_| host("")); + + let auth_url = oidc_client.auth_url(&Options { + scope: Some("openid email profile".into()), + state: Some(origin_url), + ..Default::default() + }); + + info!("authorize: {}", auth_url); + + let url: String = auth_url.into(); + + Ok(warp::reply::with_header( + StatusCode::FOUND, + warp::http::header::LOCATION, + url, + )) +} + +#[derive(Debug)] +struct Unauthorized; + +impl reject::Reject for Unauthorized {} + +async fn extract_user( + session_id: Option, + sessions: Arc>, +) -> Result { + if let Some(session_id) = session_id { + if let Some((user, _, _)) = sessions.read().await.map.get(&session_id) { + Ok(user.clone()) + } else { + Err(warp::reject::custom(Unauthorized)) + } + } else { + Err(warp::reject::custom(Unauthorized)) + } +} + +fn with_user( + sessions: Arc>, +) -> impl Filter + Clone { + warp::cookie::optional(EXAMPLE_COOKIE) + .and(warp::any().map(move || sessions.clone())) + .and_then(extract_user) +} + +async fn handle_rejections(err: Rejection) -> Result { + let code = if err.is_not_found() { + StatusCode::NOT_FOUND + } else if let Some(Unauthorized) = err.find() { + StatusCode::UNAUTHORIZED + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + + Ok(warp::reply::with_status(warp::reply(), code)) +} + +/// This host is the address, where user would be redirected after initial authorization. +/// For DEV environment with WebPack this is usually something like `http://localhost:9000`. +/// We are using `http://localhost:8080` in all-in-one example. +pub fn host(path: &str) -> String { + env::var("REDIRECT_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()) + path +} ``` See full example: [openid-examples: warp](https://github.com/kilork/openid-examples/blob/v0.16/examples/warp.rs) diff --git a/release.sh b/release.sh index 608ba2c..a4b6856 100755 --- a/release.sh +++ b/release.sh @@ -6,16 +6,19 @@ RELEASE_TYPE=${RELEASE_TYPE:-minor} cargo set-version --bump ${RELEASE_TYPE} VERSION=`cargo pkgid | cut -d"#" -f2` export OPENID_RUST_MAJOR_VERSION=`echo ${VERSION} | cut -d"." -f1,2` -pushd ../openid-examples -git checkout main -git pull -cargo upgrade -p openid@${OPENID_RUST_MAJOR_VERSION} -cargo update -git add . -git commit -m"openid version ${OPENID_RUST_MAJOR_VERSION}" -git branch v${OPENID_RUST_MAJOR_VERSION} -git push -popd +if [ "" != "patch"]; then + pushd ../openid-examples + git checkout main + git pull + cargo upgrade -p openid@${OPENID_RUST_MAJOR_VERSION} + cargo update + git add . + git commit -m"openid version ${OPENID_RUST_MAJOR_VERSION}" + git branch v${OPENID_RUST_MAJOR_VERSION} + git push + git push origin v${OPENID_RUST_MAJOR_VERSION} + popd +fi handlebars-magic templates . git add . git commit -m"Release v${VERSION}"