From b6d941dda3beffc17e70ac9f99abeb7c08a2d2be Mon Sep 17 00:00:00 2001 From: Emmanuel Gautier Date: Fri, 8 Mar 2024 14:32:46 +0100 Subject: [PATCH] feat: bootstrap baffao bff oauth implementation --- .gitignore | 14 ++++ Cargo.toml | 7 ++ README.md | 8 ++- baffao-core/Cargo.toml | 16 +++++ baffao-core/src/cookies.rs | 15 +++++ baffao-core/src/lib.rs | 3 + baffao-core/src/oauth/authorize.rs | 33 ++++++++++ baffao-core/src/oauth/callback.rs | 45 +++++++++++++ baffao-core/src/oauth/client.rs | 69 ++++++++++++++++++++ baffao-core/src/oauth/mod.rs | 3 + baffao-core/src/settings.rs | 98 ++++++++++++++++++++++++++++ baffao-proxy/Cargo.toml | 20 ++++++ baffao-proxy/config/.gitignore | 1 + baffao-proxy/config/default.toml | 29 ++++++++ baffao-proxy/config/development.toml | 31 +++++++++ baffao-proxy/config/production.toml | 1 + baffao-proxy/src/main.rs | 80 +++++++++++++++++++++++ baffao-proxy/src/oauth.rs | 43 ++++++++++++ baffao-proxy/src/sessions.rs | 34 ++++++++++ baffao-proxy/src/settings.rs | 31 +++++++++ 20 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 baffao-core/Cargo.toml create mode 100644 baffao-core/src/cookies.rs create mode 100644 baffao-core/src/lib.rs create mode 100644 baffao-core/src/oauth/authorize.rs create mode 100644 baffao-core/src/oauth/callback.rs create mode 100644 baffao-core/src/oauth/client.rs create mode 100644 baffao-core/src/oauth/mod.rs create mode 100644 baffao-core/src/settings.rs create mode 100644 baffao-proxy/Cargo.toml create mode 100644 baffao-proxy/config/.gitignore create mode 100644 baffao-proxy/config/default.toml create mode 100644 baffao-proxy/config/development.toml create mode 100644 baffao-proxy/config/production.toml create mode 100644 baffao-proxy/src/main.rs create mode 100644 baffao-proxy/src/oauth.rs create mode 100644 baffao-proxy/src/sessions.rs create mode 100644 baffao-proxy/src/settings.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6985cf1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..81ca844 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +resolver = "2" + +members = [ + "baffao-core", + "baffao-proxy", +] diff --git a/README.md b/README.md index c123274..b535854 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ -# Baffao : The BAckend For Frontend Authx Oriented +# Baffao: The BAckend For Frontend Authentication and Authorization Oriented + +Baffao is a lightweight component which implement the Backend For Frontend (BFF) pattern and provides authentication and authorization features for web applications. More specifically, it provides a more secure and efficient way to perform OAuth2 and OpenID Connect flows. + +## References + +- [IETF OAuth 2.0 for Browser-Based Apps](https://github.com/oauth-wg/oauth-browser-based-apps) diff --git a/baffao-core/Cargo.toml b/baffao-core/Cargo.toml new file mode 100644 index 0000000..928a2da --- /dev/null +++ b/baffao-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "baffao-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.80" +axum-extra = { version = "0.9.2", features = ["cookie-private"] } +config = "0.14.0" +cookie = "0.18.0" +jsonwebtoken = "9.2.0" +oauth2 = "4.4.2" +reqwest = "0.11.24" +serde = "1.0.197" diff --git a/baffao-core/src/cookies.rs b/baffao-core/src/cookies.rs new file mode 100644 index 0000000..a94c6de --- /dev/null +++ b/baffao-core/src/cookies.rs @@ -0,0 +1,15 @@ +use cookie::Cookie; +use crate::settings; + +pub fn new_cookie( + config: settings::CookieConfig, + value: String, +) -> Cookie<'static> { + Cookie::build((config.name, value)) + .domain(config.domain) + .path("/") + .secure(config.secure) + .http_only(config.http_only) + .same_site(config.same_site) + .build() +} diff --git a/baffao-core/src/lib.rs b/baffao-core/src/lib.rs new file mode 100644 index 0000000..f56b372 --- /dev/null +++ b/baffao-core/src/lib.rs @@ -0,0 +1,3 @@ +pub mod oauth; +pub mod cookies; +pub mod settings; diff --git a/baffao-core/src/oauth/authorize.rs b/baffao-core/src/oauth/authorize.rs new file mode 100644 index 0000000..09e5a36 --- /dev/null +++ b/baffao-core/src/oauth/authorize.rs @@ -0,0 +1,33 @@ +use anyhow::{Error, Ok}; +use axum_extra::extract::CookieJar; +use serde::Deserialize; + +use crate::{cookies::new_cookie, oauth::client::OAuthClient, settings::CookiesConfig}; + +#[derive(Debug, Deserialize)] +pub struct AuthorizationQuery { + pub scope: Option, +} + +pub fn oauth2_authorize( + jar: CookieJar, + query: Option, + client: OAuthClient, + CookiesConfig { + csrf: csrf_cookie, .. + }: CookiesConfig, +) -> Result<(CookieJar, String), Error> { + let (url, csrf_token) = client.get_authorization_url( + query + .map(|q| q.scope.unwrap_or_default()) + .unwrap_or_default() + .split_whitespace() + .map(|s| s.to_string()) + .collect(), + ); + + Ok(( + jar.add(new_cookie(csrf_cookie, csrf_token.secret().to_string())), + url.to_string(), + )) +} diff --git a/baffao-core/src/oauth/callback.rs b/baffao-core/src/oauth/callback.rs new file mode 100644 index 0000000..59278c0 --- /dev/null +++ b/baffao-core/src/oauth/callback.rs @@ -0,0 +1,45 @@ +use anyhow::{Error, Ok}; +use axum_extra::extract::CookieJar; +use serde::Deserialize; + +use crate::{cookies::new_cookie, oauth::client::OAuthClient, settings::CookiesConfig}; + +#[derive(Debug, Deserialize, Default)] +pub struct AuthorizationCallbackQuery { + pub code: String, + pub state: String, +} + +pub async fn oauth2_callback( + jar: CookieJar, + query: AuthorizationCallbackQuery, + client: OAuthClient, + CookiesConfig { + csrf: csrf_cookie, + access_token: access_token_cookie, + refresh_token: refresh_token_cookie, + .. + }: CookiesConfig, +) -> Result<(CookieJar, String), Error> { + let pkce_code = jar + .get(csrf_cookie.name.as_str()) + .map(|cookie| cookie.value().to_string()) + .unwrap_or_default(); + let (access_token, refresh_token, _expires) = client + .exchange_code(query.code, pkce_code, query.state.clone()) + .await + .unwrap(); + + let mut new_jar = jar.remove(csrf_cookie.name).add(new_cookie( + access_token_cookie, + access_token.secret().to_string(), + )); + if let Some(refresh_token) = refresh_token { + new_jar = new_jar.add(new_cookie( + refresh_token_cookie, + refresh_token.secret().to_string(), + )); + } + + Ok((new_jar, "/".to_string())) +} diff --git a/baffao-core/src/oauth/client.rs b/baffao-core/src/oauth/client.rs new file mode 100644 index 0000000..3c3f4fb --- /dev/null +++ b/baffao-core/src/oauth/client.rs @@ -0,0 +1,69 @@ +use std::time::Duration; + +use anyhow::Context; +use oauth2::{ + basic::{BasicClient, BasicTokenType}, reqwest::async_http_client, AccessToken, AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl, RefreshToken, Scope, StandardErrorResponse, TokenResponse, TokenUrl +}; +use reqwest::Url; + +use crate::settings::OAuthConfig; + +#[derive(Debug)] +pub struct OAuthClient { + config: OAuthConfig, + client: BasicClient, +} + +impl Clone for OAuthClient { + fn clone(&self) -> Self { + OAuthClient { + config: self.config.clone(), + client: self.client.clone(), + } + } +} + +impl OAuthClient { + pub fn new(config: OAuthConfig) -> Self { + let client = BasicClient::new( + ClientId::new(config.client_id.clone()), + Some(ClientSecret::new(config.client_secret.clone())), + AuthUrl::new(config.authorization_url.clone()).unwrap(), + Some(TokenUrl::new(config.token_url.clone()).unwrap()), + ) + .set_auth_type(AuthType::RequestBody) + .set_redirect_uri(RedirectUrl::new(config.authorization_redirect_uri.clone()).unwrap()); + + Self { config, client } + } + + pub fn get_authorization_url(&self, scope: Vec) -> (Url, CsrfToken) { + let mut request = self.client.authorize_url(CsrfToken::new_random); + if !scope.is_empty() { + request = request.add_scope(Scope::new(scope.join(" "))); + } + + let (auth_url, csrf_token) = request.url(); + (auth_url, csrf_token) + } + + pub async fn exchange_code( + &self, + code: String, + csrf_token: String, + state: String, + ) -> Result<(AccessToken, Option, Option), anyhow::Error> { + if state != csrf_token { + return Err(anyhow::anyhow!("Invalid state")); + } + + let code = AuthorizationCode::new(code); + let token = self.client + .exchange_code(code) + .request_async(async_http_client) + .await + .context("Failed to exchange code")?; + + Ok((token.access_token().clone(), token.refresh_token().cloned(), token.expires_in())) + } +} diff --git a/baffao-core/src/oauth/mod.rs b/baffao-core/src/oauth/mod.rs new file mode 100644 index 0000000..74c1fe9 --- /dev/null +++ b/baffao-core/src/oauth/mod.rs @@ -0,0 +1,3 @@ +pub mod authorize; +pub mod callback; +pub mod client; diff --git a/baffao-core/src/settings.rs b/baffao-core/src/settings.rs new file mode 100644 index 0000000..a7a1d37 --- /dev/null +++ b/baffao-core/src/settings.rs @@ -0,0 +1,98 @@ +use axum_extra::extract::cookie::SameSite; +use reqwest::Url; +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +#[allow(unused)] +pub struct ServerConfig { + pub host: String, + pub port: u16, + pub base_url: String, + pub cookies: CookiesConfig, +} + +impl ServerConfig { + pub fn base_url(&self) -> String { + format!("{}:{}", self.host, self.port) + } + + pub fn scheme(&self) -> String { + Url::parse(&self.base_url).unwrap().scheme().to_string() + } + + pub fn domain(&self) -> String { + Url::parse(&self.base_url).unwrap().domain().unwrap().to_string() + } +} + +#[derive(Debug, Deserialize, Clone)] +#[allow(unused)] +pub struct OAuthConfig { + pub client_id: String, + pub client_secret: String, + pub metadata_url: Option, + pub authorization_redirect_uri: String, + pub authorization_url: String, + pub token_url: String, + pub userinfo_url: Option, + pub redirect_uri: Option, +} + +#[derive(Debug, Deserialize, Clone)] +#[allow(unused)] +pub struct JwtConfig { + pub secret: String, + pub issuer: String, +} + +#[derive(Debug, Deserialize, Clone)] +#[allow(unused)] +pub struct CookieConfig { + pub name: String, + pub domain: String, + pub secure: bool, + pub http_only: bool, + #[serde(deserialize_with = "deserialize_same_site")] + pub same_site: SameSite, +} + +fn deserialize_same_site<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s: String = serde::Deserialize::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "lax" => Ok(SameSite::Lax), + "strict" => Ok(SameSite::Strict), + "none" => Ok(SameSite::None), + _ => Err(serde::de::Error::custom("invalid value for SameSite")), + } +} + +impl CookieConfig { + pub fn to_string_with_value(&self, value: String) -> String { + let mut cookie = format!( + "{}={}; Domain={}; Path=/; SameSite={}", + self.name, value, self.domain, self.same_site + ); + + if self.secure { + cookie = format!("{}; Secure", cookie) + } + + if self.http_only { + cookie = format!("{}; HttpOnly", cookie) + } + + cookie + } +} + +#[derive(Debug, Deserialize, Clone)] +#[allow(unused)] +pub struct CookiesConfig { + pub csrf: CookieConfig, + pub access_token: CookieConfig, + pub refresh_token: CookieConfig, + pub id_token: CookieConfig, +} diff --git a/baffao-proxy/Cargo.toml b/baffao-proxy/Cargo.toml new file mode 100644 index 0000000..f354629 --- /dev/null +++ b/baffao-proxy/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "baffao-proxy" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +axum = "0.7.4" +axum-extra = { version = "0.9.2", features = ["typed-header", "cookie"] } +baffao-core = { path = "../baffao-core" } +config = "0.14.0" +oauth2 = "4.4.2" +serde = { version = "1.0", features = ["derive"] } +tokio = { "version" = "1.36.0", features = ["full"] } +tower = { version = "0.4", features = ["util", "timeout"] } +tower-http = { version = "0.5.0", features = ["add-extension", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1.0", features = ["serde", "v4"] } diff --git a/baffao-proxy/config/.gitignore b/baffao-proxy/config/.gitignore new file mode 100644 index 0000000..ac1e2a4 --- /dev/null +++ b/baffao-proxy/config/.gitignore @@ -0,0 +1 @@ +local.* diff --git a/baffao-proxy/config/default.toml b/baffao-proxy/config/default.toml new file mode 100644 index 0000000..f795057 --- /dev/null +++ b/baffao-proxy/config/default.toml @@ -0,0 +1,29 @@ +[server] +[server.cookies] + +[server.cookies.csrf] +name = "baffao.csrf_token" +secure = true +http_only = true +same_site = "Strict" + +[server.cookies.access_token] +name = "baffao.access_token" +secure = true +http_only = true +same_site = "Strict" + +[server.cookies.refresh_token] +name = "baffao.refresh_token" +secure = true +http_only = true +same_site = "Strict" + +[server.cookies.id_token] +name = "baffao.id_token" +secure = true +http_only = true +same_site = "Strict" + +[oauth] +redirect_uri = "/" diff --git a/baffao-proxy/config/development.toml b/baffao-proxy/config/development.toml new file mode 100644 index 0000000..9760e4e --- /dev/null +++ b/baffao-proxy/config/development.toml @@ -0,0 +1,31 @@ +debug = true + +[server] +host = "127.0.0.1" +port = 3000 +base_url = "http://localhost:3000" + +[server.cookies] + +[server.cookies.csrf] +domain = "" +secure = false +same_site = "Lax" + +[server.cookies.access_token] +domain = "" +secure = false +same_site = "Lax" + +[server.cookies.refresh_token] +domain = "" +secure = false +same_site = "Lax" + +[server.cookies.id_token] +domain = "" +secure = false +same_site = "Lax" + +[oauth] +authorization_redirect_uri = "http://localhost:3000/oauth/callback" diff --git a/baffao-proxy/config/production.toml b/baffao-proxy/config/production.toml new file mode 100644 index 0000000..7b95e7a --- /dev/null +++ b/baffao-proxy/config/production.toml @@ -0,0 +1 @@ +debug = false diff --git a/baffao-proxy/src/main.rs b/baffao-proxy/src/main.rs new file mode 100644 index 0000000..3cc6792 --- /dev/null +++ b/baffao-proxy/src/main.rs @@ -0,0 +1,80 @@ +// mod sessions; +mod oauth; +mod settings; + +use axum::{ + error_handling::HandleErrorLayer, extract::FromRef, http::StatusCode, routing::get, Router +}; +use baffao_core::oauth::client::OAuthClient; +use std::time::Duration; +use tower::{BoxError, ServiceBuilder}; +use tower_http::trace::TraceLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use crate::settings::Settings; + +#[tokio::main] +async fn main() { + let settings = Settings::new().unwrap(); + + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "baffao=debug,tower_http=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let oauth_client = OAuthClient::new(settings.oauth.clone()); + + let app_state = AppState { + oauth_client, + settings: settings.clone(), + }; + + let app = Router::new() + // .route("/sessions", get(sessions::get_sessions)) + .route("/oauth/authorize", get(oauth::authorize)) + .route("/oauth/callback", get(oauth::callback)) + .layer( + ServiceBuilder::new() + .layer(HandleErrorLayer::new(|error: BoxError| async move { + if error.is::() { + Ok(StatusCode::REQUEST_TIMEOUT) + } else { + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Unhandled internal error: {error}"), + )) + } + })) + .timeout(Duration::from_secs(10)) + .layer(TraceLayer::new_for_http()) + .into_inner(), + ) + .with_state(app_state); + + let listener = tokio::net::TcpListener::bind(settings.server.host + ":" + &settings.server.port.to_string()) + .await + .unwrap(); + tracing::debug!("listening on {}", listener.local_addr().unwrap()); + axum::serve(listener, app).await.unwrap(); +} + +#[derive(Clone)] +struct AppState { + oauth_client: OAuthClient, + settings: Settings, +} + +impl FromRef for OAuthClient { + fn from_ref(state: &AppState) -> Self { + state.oauth_client.clone() + } +} + +impl FromRef for Settings { + fn from_ref(state: &AppState) -> Self { + state.settings.clone() + } +} diff --git a/baffao-proxy/src/oauth.rs b/baffao-proxy/src/oauth.rs new file mode 100644 index 0000000..da43daa --- /dev/null +++ b/baffao-proxy/src/oauth.rs @@ -0,0 +1,43 @@ +use crate::Settings; + +use axum::{ + extract::{Query, State}, + response::{IntoResponse, Redirect}, +}; +use axum_extra::extract::CookieJar; +use baffao_core::oauth::{ + authorize::{oauth2_authorize, AuthorizationQuery}, + callback::{oauth2_callback, AuthorizationCallbackQuery}, + client::OAuthClient, +}; + +// TODO: use signed cookies +pub async fn authorize( + request_jar: CookieJar, + query: Option>, + State(client): State, + State(settings): State, +) -> impl IntoResponse { + let (jar, url) = oauth2_authorize( + request_jar, + query.map(|q| q.0), + client, + settings.server.cookies.clone(), + ) + .unwrap(); + + (jar, Redirect::temporary(&url.to_string())) +} + +pub async fn callback( + request_jar: CookieJar, + Query(query): Query, + State(client): State, + State(settings): State, +) -> impl IntoResponse { + let (jar, url) = oauth2_callback(request_jar, query, client, settings.server.cookies.clone()) + .await + .unwrap(); + + (jar, Redirect::temporary(&url.to_string())) +} diff --git a/baffao-proxy/src/sessions.rs b/baffao-proxy/src/sessions.rs new file mode 100644 index 0000000..a1c10d5 --- /dev/null +++ b/baffao-proxy/src/sessions.rs @@ -0,0 +1,34 @@ +use axum::{ + http::{Method, StatusCode}, + response::IntoResponse, + Json, +}; +use axum_extra::extract::cookie::CookieJar; +use baffao_core::{ + cookies::Cookies, + session::Session, +}; +use serde::Serialize; + +#[derive(Serialize)] +struct SessionsResponse { + sessions: Vec, +} + +pub async fn get_sessions(method: Method, jar: Option) -> impl IntoResponse { + let jar = jar.unwrap_or_default(); + let cookies = Cookies::new(jar); + + let sessions = cookies.extract_sessions(b"secret"); + + if method == Method::HEAD { + if sessions.is_empty() { + return (StatusCode::NO_CONTENT, Json(SessionsResponse { sessions: vec![] })); + } + + // return StatusCode::NO_CONTENT; + // TODO: make quick check instead on parsing the cookies + } + + (StatusCode::OK, Json(SessionsResponse { sessions })) +} diff --git a/baffao-proxy/src/settings.rs b/baffao-proxy/src/settings.rs new file mode 100644 index 0000000..e4e53e4 --- /dev/null +++ b/baffao-proxy/src/settings.rs @@ -0,0 +1,31 @@ +use config::{Config, ConfigError, Environment, File}; +use serde::Deserialize; +use std::env; + +use baffao_core::settings::{JwtConfig, OAuthConfig, ServerConfig}; + +#[derive(Debug, Deserialize, Clone)] +#[allow(unused)] +pub struct Settings { + pub server: ServerConfig, + pub oauth: OAuthConfig, + pub jwt: Option, + pub debug: bool, +} + +impl Settings { + pub fn new() -> Result { + let run_mode = env::var("RUN_MODE").unwrap_or_else(|_| "development".into()); + + let s = Config::builder() + .add_source(File::with_name("config/default")) + .add_source(File::with_name(&format!("config/{}", run_mode)).required(false)) + .add_source(File::with_name("config/local").required(false)) + .add_source(Environment::with_prefix("baffao")) + .build()?; + + println!("debug: {:?}", s.get_bool("debug")); + + s.try_deserialize() + } +}