feat(auth): add authentication integration
Some checks failed
continuous-integration/drone/push Build is failing

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
Kasper Juul Hermansen 2023-08-20 14:08:40 +02:00
parent 48d09c8ae3
commit e6084a7f4e
Signed by: kjuulh
GPG Key ID: 9AA7BC13CE474394
13 changed files with 409 additions and 233 deletions

8
Cargo.lock generated
View File

@ -896,24 +896,17 @@ name = "como_auth"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-graphql",
"async-graphql-axum",
"async-sqlx-session", "async-sqlx-session",
"async-trait", "async-trait",
"axum", "axum",
"axum-extra", "axum-extra",
"axum-sessions", "axum-sessions",
"clap", "clap",
"como_core",
"como_domain",
"como_gql",
"como_infrastructure",
"oauth2", "oauth2",
"openidconnect", "openidconnect",
"pretty_assertions", "pretty_assertions",
"sealed_test", "sealed_test",
"serde", "serde",
"serde_json",
"sqlx 0.6.3", "sqlx 0.6.3",
"tokio", "tokio",
"tower", "tower",
@ -985,6 +978,7 @@ dependencies = [
"axum", "axum",
"chrono", "chrono",
"clap", "clap",
"como_auth",
"como_core", "como_core",
"como_domain", "como_domain",
"rand_core", "rand_core",

View File

@ -1,7 +1,7 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::fmt::Display;
use crate::router::AppState; use crate::router::AppState;
use crate::zitadel::{IntrospectionConfig, IntrospectionState};
use async_sqlx_session::PostgresSessionStore; use async_sqlx_session::PostgresSessionStore;
use axum::extract::{FromRef, FromRequestParts, Query, State}; use axum::extract::{FromRef, FromRequestParts, Query, State};
@ -10,9 +10,9 @@ use axum::headers::{Authorization, Cookie};
use axum::http::request::Parts; use axum::http::request::Parts;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::http::{header::SET_COOKIE, HeaderMap}; use axum::http::{header::SET_COOKIE, HeaderMap};
use axum::response::{IntoResponse, Redirect}; use axum::response::{ErrorResponse, IntoResponse, Redirect};
use axum::routing::get; use axum::routing::get;
use axum::{async_trait, RequestPartsExt, Router, TypedHeader}; use axum::{async_trait, Json, RequestPartsExt, Router, TypedHeader};
use axum_sessions::async_session::{Session, SessionStore}; use axum_sessions::async_session::{Session, SessionStore};
use como_domain::users::User; use como_domain::users::User;
use como_infrastructure::register::ServiceRegister; use como_infrastructure::register::ServiceRegister;
@ -20,6 +20,7 @@ use oauth2::basic::BasicClient;
use oauth2::{reqwest::async_http_client, AuthorizationCode, CsrfToken, Scope, TokenResponse}; use oauth2::{reqwest::async_http_client, AuthorizationCode, CsrfToken, Scope, TokenResponse};
use oauth2::{RedirectUrl, TokenIntrospectionResponse}; use oauth2::{RedirectUrl, TokenIntrospectionResponse};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json;
use zitadel::oidc::introspection::introspect; use zitadel::oidc::introspection::introspect;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -27,17 +28,39 @@ pub struct ZitadelAuthParams {
return_url: Option<String>, return_url: Option<String>,
} }
pub async fn zitadel_auth(State(client): State<BasicClient>) -> impl IntoResponse { trait AnyhowExtensions<T, E>
let (auth_url, _csrf_token) = client where
.authorize_url(CsrfToken::new_random) E: Display,
.add_scope(Scope::new("identify".to_string())) {
.add_scope(Scope::new("openid".to_string())) fn into_response(self) -> Result<T, ErrorResponse>;
.url(); }
impl<T, E> AnyhowExtensions<T, E> for anyhow::Result<T, E>
Redirect::to(auth_url.as_ref()) 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 static COOKIE_NAME: &str = "SESSION"; pub async fn zitadel_auth(
State(services): State<ServiceRegister>,
) -> Result<impl IntoResponse, ErrorResponse> {
let url = services.auth_service.login().await.into_response()?;
Ok(Redirect::to(&url.to_string()))
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
@ -48,44 +71,15 @@ pub struct AuthRequest {
pub async fn login_authorized( pub async fn login_authorized(
Query(query): Query<AuthRequest>, Query(query): Query<AuthRequest>,
State(store): State<PostgresSessionStore>, State(services): State<ServiceRegister>,
State(introspection_state): State<IntrospectionState>, ) -> Result<impl IntoResponse, ErrorResponse> {
) -> impl IntoResponse { let (headers, url) = services
let token = oauth_client .auth_service
.exchange_code(AuthorizationCode::new(query.code.clone())) .login_authorized(&query.code, &query.state)
.request_async(async_http_client)
.await .await
.unwrap(); .into_response()?;
let config = IntrospectionConfig::from_ref(&introspection_state); Ok((headers, Redirect::to(url.as_str())))
let res = introspect(
&config.introspection_uri,
&config.authority,
&config.authentication,
token.access_token().secret(),
)
.await
.unwrap();
let mut session = Session::new();
session
.insert(
"user",
User {
id: res.sub().unwrap().into(),
},
)
.unwrap();
let cookie = store.store_session(session).await.unwrap().unwrap();
let cookie = format!("{}={}; SameSite=Lax; Path=/", COOKIE_NAME, cookie);
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, cookie.parse().unwrap());
(headers, Redirect::to("http://localhost:3000/dash/home"))
} }
pub struct AuthController; pub struct AuthController;
@ -106,79 +100,61 @@ pub struct UserFromSession {
pub user: User, pub user: User,
} }
pub static COOKIE_NAME: &str = "SESSION";
#[async_trait] #[async_trait]
impl<S> FromRequestParts<S> for UserFromSession impl<S> FromRequestParts<S> for UserFromSession
where where
PostgresSessionStore: FromRef<S>, ServiceRegister: FromRef<S>,
IntrospectionState: FromRef<S>,
S: Send + Sync, S: Send + Sync,
{ {
type Rejection = (StatusCode, &'static str); type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let store = PostgresSessionStore::from_ref(state); let services = ServiceRegister::from_ref(state);
let cookie: Option<TypedHeader<Cookie>> = parts.extract().await.unwrap(); let cookie: Option<TypedHeader<Cookie>> = parts.extract().await.unwrap();
let session_cookie = cookie.as_ref().and_then(|cookie| cookie.get(COOKIE_NAME)); let session_cookie = cookie.as_ref().and_then(|cookie| cookie.get(COOKIE_NAME));
if let None = session_cookie { if let None = session_cookie {
let introspection_state = IntrospectionState::from_ref(state); // let introspection_state = IntrospectionState::from_ref(state);
let basic: Option<TypedHeader<Authorization<Basic>>> = parts.extract().await.unwrap(); // let basic: Option<TypedHeader<Authorization<Basic>>> = parts.extract().await.unwrap();
if let Some(basic) = basic { // if let Some(basic) = basic {
let config = IntrospectionConfig::from_ref(&introspection_state); // let config = IntrospectionConfig::from_ref(&introspection_state);
let res = introspect( // let res = introspect(
&config.introspection_uri, // &config.introspection_uri,
&config.authority, // &config.authority,
&config.authentication, // &config.authentication,
basic.password(), // basic.password(),
) // )
.await // .await
.unwrap(); // .unwrap();
return Ok(UserFromSession { // return Ok(UserFromSession {
user: User { // user: User {
id: res.sub().unwrap().into(), // id: res.sub().unwrap().into(),
}, // },
}); // });
} // }
todo!()
return Err((StatusCode::UNAUTHORIZED, "No session was found")); //return Err(anyhow::anyhow!("No session was found")).into_response();
} }
let session_cookie = session_cookie.unwrap(); let session_cookie = session_cookie.unwrap();
tracing::debug!(
"UserFromSession: got session cookie from user agent, {}={}",
COOKIE_NAME,
session_cookie
);
// continue to decode the session cookie // continue to decode the session cookie
let user = let user = services
if let Some(session) = store.load_session(session_cookie.to_owned()).await.unwrap() { .auth_service
if let Some(user) = session.get::<User>("user") { .get_user_from_session(session_cookie)
tracing::debug!( .await
"UserFromSession: session decoded success, user_id={:?}", .into_response()
user.id .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "failed with error"))?;
);
user
} else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"No `user_id` found in session",
));
}
} else {
tracing::debug!(
"UserIdFromSession: err session not exists in store, {}={}",
COOKIE_NAME,
session_cookie
);
return Err((StatusCode::BAD_REQUEST, "No session found for cookie"));
};
Ok(UserFromSession { user }) Ok(UserFromSession {
user: User { id: user.id },
})
} }
} }

View File

@ -1,18 +1,15 @@
use std::env; use std::env;
use anyhow::Context; use anyhow::Context;
use async_sqlx_session::PostgresSessionStore;
use axum::extract::FromRef; use axum::extract::FromRef;
use axum::http::{HeaderValue, Method}; use axum::http::{HeaderValue, Method};
use axum::Router; use axum::Router;
use como_infrastructure::register::ServiceRegister; use como_infrastructure::register::ServiceRegister;
use oauth2::basic::BasicClient;
use tower::ServiceBuilder; use tower::ServiceBuilder;
use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tower_http::{cors::CorsLayer, trace::TraceLayer};
use crate::controllers::auth::AuthController; use crate::controllers::auth::AuthController;
use crate::controllers::graphql::GraphQLController; use crate::controllers::graphql::GraphQLController;
use crate::zitadel::{IntrospectionState, IntrospectionStateBuilder};
pub struct Api; pub struct Api;
@ -22,18 +19,8 @@ impl Api {
cors_origin: &str, cors_origin: &str,
service_register: ServiceRegister, service_register: ServiceRegister,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let client_id = env::var("CLIENT_ID").expect("Missing CLIENT_ID!");
let client_secret = env::var("CLIENT_SECRET").expect("Missing CLIENT_SECRET!");
let zitadel_url = env::var("ZITADEL_URL").expect("missing ZITADEL_URL");
let is = IntrospectionStateBuilder::new(&zitadel_url)
.with_basic_auth(&client_id, &client_secret)
.build()
.await?;
let app_state = AppState { let app_state = AppState {
store: service_register.session_store.clone(), service_register: service_register.clone(),
introspection_state: is,
}; };
let router = Router::new() let router = Router::new()
@ -76,18 +63,11 @@ impl Api {
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
introspection_state: IntrospectionState, service_register: ServiceRegister,
store: PostgresSessionStore,
} }
impl FromRef<AppState> for PostgresSessionStore { impl FromRef<AppState> for ServiceRegister {
fn from_ref(state: &AppState) -> Self {
state.store.clone()
}
}
impl FromRef<AppState> for IntrospectionState {
fn from_ref(input: &AppState) -> Self { fn from_ref(input: &AppState) -> Self {
input.introspection_state.clone() input.service_register.clone()
} }
} }

View File

@ -1,90 +1 @@
pub mod client;
use axum::extract::FromRef;
use openidconnect::IntrospectionUrl;
use zitadel::{
axum::introspection::IntrospectionStateBuilderError,
credentials::Application,
oidc::{discovery::discover, introspection::AuthorityAuthentication},
};
#[derive(Clone, Debug)]
pub struct IntrospectionState {
pub(crate) config: IntrospectionConfig,
}
/// Configuration that must be inject into the axum application state. Used by the
/// [IntrospectionStateBuilder](super::IntrospectionStateBuilder). This struct is also used to create the [IntrospectionState](IntrospectionState)
#[derive(Debug, Clone)]
pub struct IntrospectionConfig {
pub(crate) authority: String,
pub(crate) authentication: AuthorityAuthentication,
pub(crate) introspection_uri: IntrospectionUrl,
}
impl FromRef<IntrospectionState> for IntrospectionConfig {
fn from_ref(input: &IntrospectionState) -> Self {
input.config.clone()
}
}
pub struct IntrospectionStateBuilder {
authority: String,
authentication: Option<AuthorityAuthentication>,
}
/// Builder for [IntrospectionConfig]
impl IntrospectionStateBuilder {
pub fn new(authority: &str) -> Self {
Self {
authority: authority.to_string(),
authentication: None,
}
}
pub fn with_basic_auth(
&mut self,
client_id: &str,
client_secret: &str,
) -> &mut IntrospectionStateBuilder {
self.authentication = Some(AuthorityAuthentication::Basic {
client_id: client_id.to_string(),
client_secret: client_secret.to_string(),
});
self
}
pub fn with_jwt_profile(&mut self, application: Application) -> &mut IntrospectionStateBuilder {
self.authentication = Some(AuthorityAuthentication::JWTProfile { application });
self
}
pub async fn build(&mut self) -> Result<IntrospectionState, IntrospectionStateBuilderError> {
if self.authentication.is_none() {
return Err(IntrospectionStateBuilderError::NoAuthSchema);
}
let metadata = discover(&self.authority)
.await
.map_err(|source| IntrospectionStateBuilderError::Discovery { source })?;
let introspection_uri = metadata
.additional_metadata()
.introspection_endpoint
.clone();
if introspection_uri.is_none() {
return Err(IntrospectionStateBuilderError::NoIntrospectionUrl);
}
Ok(IntrospectionState {
config: IntrospectionConfig {
authority: self.authority.clone(),
introspection_uri: introspection_uri.unwrap(),
authentication: self.authentication.as_ref().unwrap().clone(),
},
})
}
}

View File

@ -6,21 +6,12 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
como_gql.workspace = true
como_core.workspace = true
como_domain.workspace = true
como_infrastructure.workspace = true
clap.workspace = true clap.workspace = true
async-trait.workspace = true async-trait.workspace = true
async-graphql.workspace = true
async-graphql-axum.workspace = true
axum.workspace = true axum.workspace = true
axum-extra.workspace = true axum-extra.workspace = true
axum-sessions.workspace = true axum-sessions.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
uuid.workspace = true uuid.workspace = true
sqlx.workspace = true sqlx.workspace = true
anyhow.workspace = true anyhow.workspace = true
@ -34,5 +25,6 @@ oauth2 = "4.4.0"
openidconnect = "3.0.0" openidconnect = "3.0.0"
[dev-dependencies] [dev-dependencies]
tokio.workspace = true
pretty_assertions.workspace = true pretty_assertions.workspace = true
sealed_test.workspace = true sealed_test.workspace = true

107
como_auth/src/auth.rs Normal file
View File

@ -0,0 +1,107 @@
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,
oauth::OAuth,
session::{SessionService, User},
AuthClap, AuthEngine,
};
#[async_trait]
pub trait Auth {
async fn login(&self) -> anyhow::Result<Url>;
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) -> anyhow::Result<Self> {
match config.engine {
AuthEngine::Noop => Ok(Self::new_noop()),
AuthEngine::Zitadel => Ok(Self::new_zitadel()),
}
}
pub fn new_zitadel() -> Self {
todo!()
//Self(Arc::new(ZitadelAuthService {}))
}
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 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 get_user_from_session(&self, cookie: &str) -> anyhow::Result<User> {
todo!()
}
}

View File

@ -1,12 +1,16 @@
use std::sync::Arc; use std::{ops::Deref, sync::Arc};
use async_trait::async_trait; use async_trait::async_trait;
use axum::extract::FromRef; use axum::extract::FromRef;
use oauth2::TokenIntrospectionResponse;
use openidconnect::IntrospectionUrl; use openidconnect::IntrospectionUrl;
use zitadel::{ use zitadel::{
axum::introspection::IntrospectionStateBuilderError, axum::introspection::IntrospectionStateBuilderError,
credentials::Application, credentials::Application,
oidc::{discovery::discover, introspection::AuthorityAuthentication}, oidc::{
discovery::discover,
introspection::{introspect, AuthorityAuthentication},
},
}; };
use crate::AuthClap; use crate::AuthClap;
@ -14,10 +18,10 @@ use crate::AuthClap;
#[async_trait] #[async_trait]
pub trait Introspection { pub trait Introspection {
async fn get_user(&self) -> anyhow::Result<()>; async fn get_user(&self) -> anyhow::Result<()>;
async fn get_id_token(&self, token: &str) -> anyhow::Result<String>;
} }
pub struct IntrospectionService(Arc<dyn Introspection + Send + Sync + 'static>); pub struct IntrospectionService(Arc<dyn Introspection + Send + Sync + 'static>);
impl IntrospectionService { impl IntrospectionService {
pub async fn new_zitadel(config: &AuthClap) -> anyhow::Result<Self> { pub async fn new_zitadel(config: &AuthClap) -> anyhow::Result<Self> {
let res = IntrospectionStateBuilder::new(&config.zitadel.authority_url.clone().unwrap()) let res = IntrospectionStateBuilder::new(&config.zitadel.authority_url.clone().unwrap())
@ -34,6 +38,14 @@ impl IntrospectionService {
} }
} }
impl Deref for IntrospectionService {
type Target = Arc<dyn Introspection + Send + Sync + 'static>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub struct ZitadelIntrospection { pub struct ZitadelIntrospection {
state: IntrospectionState, state: IntrospectionState,
} }
@ -49,6 +61,21 @@ impl Introspection for ZitadelIntrospection {
async fn get_user(&self) -> anyhow::Result<()> { async fn get_user(&self) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn get_id_token(&self, token: &str) -> anyhow::Result<String> {
let config = &self.state.config;
let res = introspect(
&config.introspection_uri,
&config.authority,
&config.authentication,
token,
)
.await?;
Ok(res
.sub()
.ok_or(anyhow::anyhow!("could not find a userid (sub) in token"))?
.to_string())
}
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View File

@ -1,8 +1,13 @@
use oauth::{OAuth, ZitadelConfig}; use oauth::{OAuth, ZitadelConfig};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
mod auth;
mod introspection; mod introspection;
mod oauth; mod oauth;
mod session;
pub use auth::{Auth, AuthService};
use session::SessionClap;
#[derive(clap::ValueEnum, Clone, PartialEq, Eq, Debug)] #[derive(clap::ValueEnum, Clone, PartialEq, Eq, Debug)]
pub enum AuthEngine { pub enum AuthEngine {
@ -10,6 +15,12 @@ pub enum AuthEngine {
Zitadel, Zitadel,
} }
#[derive(clap::ValueEnum, Clone, PartialEq, Eq, Debug)]
pub enum SessionBackend {
InMemory,
Postgresql,
}
#[derive(clap::Args, Clone, PartialEq, Eq, Debug)] #[derive(clap::Args, Clone, PartialEq, Eq, Debug)]
pub struct AuthClap { pub struct AuthClap {
#[arg( #[arg(
@ -22,8 +33,21 @@ pub struct AuthClap {
] ]
pub engine: AuthEngine, pub engine: AuthEngine,
#[arg(
env = "SESSION_BACKEND",
long = "session-backend",
requires_ifs = [
( "postgresql", "PostgresqlSessionClap" )
],
default_value = "in-memory" )
]
pub session_backend: SessionBackend,
#[clap(flatten)] #[clap(flatten)]
pub zitadel: ZitadelClap, pub zitadel: ZitadelClap,
#[clap(flatten)]
pub session: SessionClap,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -105,7 +129,10 @@ impl AuthClap {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::{AuthClap, AuthEngine, ZitadelClap}; use crate::{
session::{PostgresqlSessionClap, SessionClap},
AuthClap, AuthEngine, SessionBackend, ZitadelClap,
};
use clap::Parser; use clap::Parser;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
@ -141,6 +168,10 @@ mod test {
token_url: None, token_url: None,
authority_url: None, authority_url: None,
}, },
session_backend: SessionBackend::InMemory,
session: SessionClap {
postgresql: PostgresqlSessionClap { conn: None }
}
} }
} }
); );
@ -163,6 +194,10 @@ mod test {
token_url: None, token_url: None,
authority_url: None, authority_url: None,
}, },
session_backend: crate::SessionBackend::InMemory,
session: crate::SessionClap {
postgresql: PostgresqlSessionClap { conn: None }
}
} }
} }
); );
@ -195,6 +230,10 @@ mod test {
token_url: Some("https://something".into()), token_url: Some("https://something".into()),
authority_url: Some("https://something".into()), authority_url: Some("https://something".into()),
}, },
session_backend: crate::SessionBackend::InMemory,
session: crate::SessionClap {
postgresql: PostgresqlSessionClap { conn: None }
}
}, },
} }
); );

View File

@ -1,5 +1,8 @@
use async_trait::async_trait; use async_trait::async_trait;
use oauth2::reqwest::async_http_client;
use oauth2::url::Url;
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use oauth2::{AuthorizationCode, CsrfToken, Scope, TokenResponse};
use std::ops::Deref; use std::ops::Deref;
use std::sync::Arc; use std::sync::Arc;
@ -8,6 +11,8 @@ use crate::ZitadelClap;
#[async_trait] #[async_trait]
pub trait OAuthClient { pub trait OAuthClient {
async fn get_token(&self) -> anyhow::Result<()>; async fn get_token(&self) -> anyhow::Result<()>;
async fn authorize_url(&self) -> anyhow::Result<Url>;
async fn exchange(&self, code: &str) -> anyhow::Result<String>;
} }
pub struct OAuth(Arc<dyn OAuthClient + Send + Sync + 'static>); pub struct OAuth(Arc<dyn OAuthClient + Send + Sync + 'static>);
@ -43,6 +48,13 @@ impl OAuthClient for NoopOAuthClient {
async fn get_token(&self) -> anyhow::Result<()> { async fn get_token(&self) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn authorize_url(&self) -> anyhow::Result<Url> {
Ok(Url::parse("http://localhost:3000/auth/zitadel").unwrap())
}
async fn exchange(&self, code: &str) -> anyhow::Result<String> {
Ok(String::new())
}
} }
// -- Zitadel // -- Zitadel
@ -138,6 +150,26 @@ impl OAuthClient for ZitadelOAuthClient {
async fn get_token(&self) -> anyhow::Result<()> { async fn get_token(&self) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn authorize_url(&self) -> anyhow::Result<Url> {
let (auth_url, _csrf_token) = self
.client
.authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("identify".to_string()))
.add_scope(Scope::new("openid".to_string()))
.url();
Ok(auth_url)
}
async fn exchange(&self, code: &str) -> anyhow::Result<String> {
let token = self
.client
.exchange_code(AuthorizationCode::new(code.to_string()))
.request_async(async_http_client)
.await?;
Ok(token.access_token().secret().clone())
}
} }
#[cfg(test)] #[cfg(test)]

108
como_auth/src/session.rs Normal file
View File

@ -0,0 +1,108 @@
use std::{ops::Deref, sync::Arc};
use async_sqlx_session::PostgresSessionStore;
use async_trait::async_trait;
use axum_sessions::async_session::{Session as AxumSession, SessionStore as AxumSessionStore};
use serde::{Deserialize, Serialize};
use crate::{AuthClap, SessionBackend};
#[derive(clap::Args, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionClap {
#[clap(flatten)]
pub postgresql: PostgresqlSessionClap,
}
#[derive(clap::Args, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostgresqlSessionClap {
#[arg(env = "SESSION_POSTGRES_CONN", long = "session-postgres-conn")]
pub conn: Option<String>,
}
#[async_trait]
pub trait Session {
async fn insert_user(&self, id: &str, user_id: &str) -> anyhow::Result<String>;
async fn get_user(&self, cookie: &str) -> anyhow::Result<Option<String>>;
}
pub struct SessionService(Arc<dyn Session + Send + Sync + 'static>);
impl SessionService {
pub async fn new(config: &AuthClap) -> anyhow::Result<Self> {
match config.session_backend {
SessionBackend::InMemory => Ok(Self(Arc::new(InMemorySessionService {}))),
SessionBackend::Postgresql => {
Ok(Self(Arc::new(PostgresSessionService { store: todo!() })))
}
}
}
}
impl Deref for SessionService {
type Target = Arc<dyn Session + Send + Sync + 'static>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub struct PostgresSessionService {
store: PostgresSessionStore,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
pub id: String,
}
#[async_trait]
impl Session for PostgresSessionService {
async fn insert_user(&self, id: &str, user_id: &str) -> anyhow::Result<String> {
let mut session = AxumSession::new();
session.insert(
"user",
User {
id: user_id.to_string(),
},
)?;
let cookie = self
.store
.store_session(session)
.await?
.ok_or(anyhow::anyhow!("failed to store session"))?;
Ok(cookie)
}
async fn get_user(&self, cookie: &str) -> anyhow::Result<Option<String>> {
if let Some(session) = self.store.load_session(cookie.to_string()).await.unwrap() {
if let Some(user) = session.get::<User>("user") {
tracing::debug!(
"UserFromSession: session decoded success, user_id={:?}",
user.id
);
Ok(Some(user.id))
} else {
Ok(None)
}
} else {
tracing::debug!(
"UserIdFromSession: err session not exists in store, {}",
cookie
);
Err(anyhow::anyhow!("No session found for cookie"))
}
}
}
pub struct InMemorySessionService {}
#[async_trait]
impl Session for InMemorySessionService {
async fn insert_user(&self, id: &str, user_id: &str) -> anyhow::Result<String> {
todo!()
}
async fn get_user(&self, cookie: &str) -> anyhow::Result<Option<String>> {
todo!()
}
}

View File

@ -8,7 +8,7 @@ edition = "2021"
[dependencies] [dependencies]
como_core.workspace = true como_core.workspace = true
como_domain.workspace = true como_domain.workspace = true
como_auth.workspace = true
axum.workspace = true axum.workspace = true
async-trait.workspace = true async-trait.workspace = true

View File

@ -1,4 +1,5 @@
use clap::ValueEnum; use clap::ValueEnum;
use como_auth::AuthClap;
#[derive(clap::Parser)] #[derive(clap::Parser)]
pub struct AppConfig { pub struct AppConfig {
@ -18,6 +19,9 @@ pub struct AppConfig {
pub seed: bool, pub seed: bool,
#[clap(long, env)] #[clap(long, env)]
pub cors_origin: String, pub cors_origin: String,
#[clap(flatten)]
pub auth: AuthClap,
} }
#[derive(Clone, Debug, ValueEnum)] #[derive(Clone, Debug, ValueEnum)]

View File

@ -1,6 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use async_sqlx_session::PostgresSessionStore; use async_sqlx_session::PostgresSessionStore;
use como_auth::AuthService;
use como_core::{items::DynItemService, projects::DynProjectService, users::DynUserService}; use como_core::{items::DynItemService, projects::DynProjectService, users::DynUserService};
use tracing::log::info; use tracing::log::info;
@ -20,12 +21,15 @@ pub struct ServiceRegister {
pub project_service: DynProjectService, pub project_service: DynProjectService,
pub user_service: DynUserService, pub user_service: DynUserService,
pub session_store: PostgresSessionStore, pub session_store: PostgresSessionStore,
pub auth_service: AuthService,
} }
impl ServiceRegister { impl ServiceRegister {
pub async fn new(pool: ConnectionPool, config: Arc<AppConfig>) -> anyhow::Result<Self> { pub async fn new(pool: ConnectionPool, config: Arc<AppConfig>) -> anyhow::Result<Self> {
info!("creating services"); info!("creating services");
let auth = AuthService::new(&config.auth).await?;
let s = match config.database_type { let s = match config.database_type {
DatabaseType::Postgres => { DatabaseType::Postgres => {
let item_service = let item_service =
@ -42,6 +46,7 @@ impl ServiceRegister {
user_service, user_service,
project_service, project_service,
session_store: store, session_store: store,
auth_service: auth,
} }
} }
DatabaseType::InMemory => { DatabaseType::InMemory => {
@ -57,6 +62,7 @@ impl ServiceRegister {
user_service, user_service,
project_service, project_service,
session_store: store, session_store: store,
auth_service: auth,
} }
} }
}; };