feat: add basic example

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2023-10-22 18:41:37 +02:00
parent f1cff02673
commit 131867ef61
9 changed files with 513 additions and 142 deletions

View File

@@ -10,6 +10,7 @@ axum.workspace = true
axum-extra.workspace = true
axum-sessions.workspace = true
serde.workspace = true
serde_json.workspace = true
uuid.workspace = true
sqlx.workspace = true
anyhow.workspace = true

View File

@@ -0,0 +1,128 @@
use std::{ops::Deref, sync::Arc};
use anyhow::Context;
use async_trait::async_trait;
use axum::http::{header::SET_COOKIE, HeaderMap};
use oauth2::url::Url;
use crate::{
introspection::IntrospectionService,
login::{config::AuthEngine, AuthClap},
oauth::{zitadel::ZitadelConfig, OAuth},
session::{SessionService, User},
};
#[async_trait]
pub trait Auth {
async fn login(&self) -> anyhow::Result<Url>;
async fn login_token(&self, user: &str, password: &str) -> anyhow::Result<String>;
async fn login_authorized(&self, code: &str, state: &str) -> anyhow::Result<(HeaderMap, Url)>;
async fn get_user_from_session(&self, cookie: &str) -> anyhow::Result<User>;
}
#[derive(Clone)]
pub struct AuthService(Arc<dyn Auth + Send + Sync + 'static>);
impl AuthService {
pub async fn new(config: &AuthClap, session: SessionService) -> anyhow::Result<Self> {
match config.engine {
AuthEngine::Noop => Ok(Self::new_noop()),
AuthEngine::Zitadel => {
let oauth: OAuth = ZitadelConfig::try_from(config.zitadel.clone())?.into();
let introspection: IntrospectionService =
IntrospectionService::new_zitadel(config).await?;
Ok(Self::new_zitadel(oauth, introspection, session))
}
}
}
pub fn new_zitadel(
oauth: OAuth,
introspection: IntrospectionService,
session: SessionService,
) -> Self {
Self(Arc::new(ZitadelAuthService {
oauth,
introspection,
session,
}))
}
pub fn new_noop() -> Self {
Self(Arc::new(NoopAuthService {}))
}
}
impl Deref for AuthService {
type Target = Arc<dyn Auth + Send + Sync + 'static>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub struct ZitadelAuthService {
oauth: OAuth,
introspection: IntrospectionService,
session: SessionService,
}
pub static COOKIE_NAME: &str = "SESSION";
#[async_trait]
impl Auth for ZitadelAuthService {
async fn login(&self) -> anyhow::Result<Url> {
let authorize_url = self.oauth.authorize_url().await?;
Ok(authorize_url)
}
async fn login_authorized(&self, code: &str, _state: &str) -> anyhow::Result<(HeaderMap, Url)> {
let token = self.oauth.exchange(code).await?;
let user_id = self.introspection.get_id_token(token.as_str()).await?;
let cookie_value = self.session.insert_user("user", user_id.as_str()).await?;
let cookie = format!("{}={}; SameSite=Lax; Path=/", COOKIE_NAME, cookie_value);
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, cookie.parse().unwrap());
Ok((
headers,
Url::parse("http://localhost:3000/dash/home")
.context("failed to parse login_authorized zitadel return url")?,
))
}
async fn login_token(&self, _user: &str, password: &str) -> anyhow::Result<String> {
self.introspection.get_id_token(password).await
}
async fn get_user_from_session(&self, cookie: &str) -> anyhow::Result<User> {
match self.session.get_user(cookie).await? {
Some(u) => Ok(User { id: u }),
None => Err(anyhow::anyhow!("failed to find user")),
}
}
}
pub struct NoopAuthService {}
#[async_trait]
impl Auth for NoopAuthService {
async fn login(&self) -> anyhow::Result<Url> {
todo!()
}
async fn login_authorized(
&self,
_code: &str,
_state: &str,
) -> anyhow::Result<(HeaderMap, Url)> {
todo!()
}
async fn login_token(&self, _user: &str, _password: &str) -> anyhow::Result<String> {
todo!()
}
async fn get_user_from_session(&self, _cookie: &str) -> anyhow::Result<User> {
todo!()
}
}

View File

@@ -0,0 +1,152 @@
use std::fmt::Display;
use axum::extract::{FromRef, FromRequestParts, Query, State};
use axum::headers::authorization::Basic;
use axum::headers::{Authorization, Cookie};
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::response::{ErrorResponse, IntoResponse, Redirect};
use axum::routing::get;
use axum::{async_trait, Json, RequestPartsExt, Router, TypedHeader};
use serde::Deserialize;
use serde_json::json;
use crate::auth::AuthService;
use crate::session::User;
#[derive(Debug, Deserialize)]
pub struct ZitadelAuthParams {
#[allow(dead_code)]
return_url: Option<String>,
}
trait AnyhowExtensions<T, E>
where
E: Display,
{
fn into_response(self) -> Result<T, ErrorResponse>;
}
impl<T, E> AnyhowExtensions<T, E> for anyhow::Result<T, E>
where
E: Display,
{
fn into_response(self) -> Result<T, ErrorResponse> {
match self {
Ok(o) => Ok(o),
Err(e) => {
tracing::error!("failed with anyhow error: {}", e);
Err(ErrorResponse::from((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"status": "something",
})),
)))
}
}
}
}
pub async fn zitadel_auth(
State(auth_service): State<AuthService>,
) -> Result<impl IntoResponse, ErrorResponse> {
let url = auth_service.login().await.into_response()?;
Ok(Redirect::to(url.as_ref()))
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct AuthRequest {
code: String,
state: String,
}
pub async fn login_authorized(
Query(query): Query<AuthRequest>,
State(auth_service): State<AuthService>,
) -> Result<impl IntoResponse, ErrorResponse> {
let (headers, url) = auth_service
.login_authorized(&query.code, &query.state)
.await
.into_response()?;
Ok((headers, Redirect::to(url.as_str())))
}
pub struct AuthController;
impl AuthController {
pub async fn new_router(auth_service: AuthService) -> anyhow::Result<Router> {
Ok(Router::new()
.route("/zitadel", get(zitadel_auth))
.route("/authorized", get(login_authorized))
.with_state(auth_service))
}
}
pub struct UserFromSession {
pub user: User,
}
pub static COOKIE_NAME: &str = "SESSION";
#[async_trait]
impl<S> FromRequestParts<S> for UserFromSession
where
AuthService: FromRef<S>,
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let auth_service = AuthService::from_ref(state);
let cookie: Option<TypedHeader<Cookie>> = parts.extract().await.unwrap();
let session_cookie = cookie.as_ref().and_then(|cookie| cookie.get(COOKIE_NAME));
if session_cookie.is_none() {
let basic: Option<TypedHeader<Authorization<Basic>>> = parts.extract().await.unwrap();
if let Some(basic) = basic {
let token = auth_service
.login_token(basic.username(), basic.password())
.await
.into_response()
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"could not get token from basic",
)
})?;
return Ok(UserFromSession {
user: User { id: token },
});
}
return Err(anyhow::anyhow!("No session was found"))
.into_response()
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "did not find a cookie"))?;
}
let session_cookie = session_cookie.unwrap();
// continue to decode the session cookie
let user = auth_service
.get_user_from_session(session_cookie)
.await
.into_response()
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to decode session cookie",
)
})?;
Ok(UserFromSession {
user: User { id: user.id },
})
}
}

View File

@@ -1,148 +1,10 @@
pub mod auth;
pub mod axum;
pub mod introspection;
pub mod login;
pub mod oauth;
pub mod session;
pub mod auth {
use std::{ops::Deref, sync::Arc};
use anyhow::Context;
use async_trait::async_trait;
use axum::http::{header::SET_COOKIE, HeaderMap};
use oauth2::url::Url;
use crate::{
introspection::IntrospectionService,
login::{config::AuthEngine, AuthClap},
oauth::{zitadel::ZitadelConfig, OAuth},
session::{SessionService, User},
};
#[async_trait]
pub trait Auth {
async fn login(&self) -> anyhow::Result<Url>;
async fn login_token(&self, user: &str, password: &str) -> anyhow::Result<String>;
async fn login_authorized(
&self,
code: &str,
state: &str,
) -> anyhow::Result<(HeaderMap, Url)>;
async fn get_user_from_session(&self, cookie: &str) -> anyhow::Result<User>;
}
#[derive(Clone)]
pub struct AuthService(Arc<dyn Auth + Send + Sync + 'static>);
impl AuthService {
pub async fn new(config: &AuthClap, session: SessionService) -> anyhow::Result<Self> {
match config.engine {
AuthEngine::Noop => Ok(Self::new_noop()),
AuthEngine::Zitadel => {
let oauth: OAuth = ZitadelConfig::try_from(config.zitadel.clone())?.into();
let introspection: IntrospectionService =
IntrospectionService::new_zitadel(config).await?;
Ok(Self::new_zitadel(oauth, introspection, session))
}
}
}
pub fn new_zitadel(
oauth: OAuth,
introspection: IntrospectionService,
session: SessionService,
) -> Self {
Self(Arc::new(ZitadelAuthService {
oauth,
introspection,
session,
}))
}
pub fn new_noop() -> Self {
Self(Arc::new(NoopAuthService {}))
}
}
impl Deref for AuthService {
type Target = Arc<dyn Auth + Send + Sync + 'static>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub struct ZitadelAuthService {
oauth: OAuth,
introspection: IntrospectionService,
session: SessionService,
}
pub static COOKIE_NAME: &str = "SESSION";
#[async_trait]
impl Auth for ZitadelAuthService {
async fn login(&self) -> anyhow::Result<Url> {
let authorize_url = self.oauth.authorize_url().await?;
Ok(authorize_url)
}
async fn login_authorized(
&self,
code: &str,
_state: &str,
) -> anyhow::Result<(HeaderMap, Url)> {
let token = self.oauth.exchange(code).await?;
let user_id = self.introspection.get_id_token(token.as_str()).await?;
let cookie_value = self.session.insert_user("user", user_id.as_str()).await?;
let cookie = format!("{}={}; SameSite=Lax; Path=/", COOKIE_NAME, cookie_value);
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, cookie.parse().unwrap());
Ok((
headers,
Url::parse("http://localhost:3000/dash/home")
.context("failed to parse login_authorized zitadel return url")?,
))
}
async fn login_token(&self, _user: &str, password: &str) -> anyhow::Result<String> {
self.introspection.get_id_token(password).await
}
async fn get_user_from_session(&self, cookie: &str) -> anyhow::Result<User> {
match self.session.get_user(cookie).await? {
Some(u) => Ok(User { id: u }),
None => Err(anyhow::anyhow!("failed to find user")),
}
}
}
pub struct NoopAuthService {}
#[async_trait]
impl Auth for NoopAuthService {
async fn login(&self) -> anyhow::Result<Url> {
todo!()
}
async fn login_authorized(
&self,
_code: &str,
_state: &str,
) -> anyhow::Result<(HeaderMap, Url)> {
todo!()
}
async fn login_token(&self, _user: &str, _password: &str) -> anyhow::Result<String> {
todo!()
}
async fn get_user_from_session(&self, _cookie: &str) -> anyhow::Result<User> {
todo!()
}
}
}
#[cfg(test)]
mod test {
use clap::Parser;

View File

@@ -32,6 +32,7 @@ pub trait Session {
}
pub struct SessionService(Arc<dyn Session + Send + Sync + 'static>);
impl SessionService {
pub async fn new(config: &AuthClap) -> anyhow::Result<Self> {
match config.session_backend {