Compare commits

..

18 Commits

Author SHA1 Message Date
63c29999bf
feat: update
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-03-30 22:37:23 +01:00
a470122745
feat: adding drone
Some checks failed
continuous-integration/drone/push Build is failing
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-03-30 22:11:42 +01:00
d32d343695
something 2024-03-30 21:59:02 +01:00
251d3922bf
something 2024-03-30 21:58:41 +01:00
1b8924e7f6
feat: will trigger login if no cookie is found
Some checks are pending
ci/woodpecker/push/test Pipeline is pending
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-30 10:07:27 +01:00
f586d157b1 Merge pull request 'feat/axum-0.7.x - uses breaking changes from axum 0.7.x' (#4) from feat/axum-0.7.x into main
Some checks are pending
ci/woodpecker/push/test Pipeline is pending
Reviewed-on: #4
2023-11-28 10:46:43 +00:00
66080374b0
feat: with upstream axum
Some checks are pending
ci/woodpecker/pr/test Pipeline is pending
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-28 11:46:11 +01:00
92e435e080
feat: with return url
Some checks are pending
ci/woodpecker/pr/test Pipeline is pending
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-21 22:52:22 +01:00
9510b8fc42
feat: with return url
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-21 21:53:39 +01:00
6bf847a24a
feat: minor update
Some checks are pending
ci/woodpecker/pr/test Pipeline is pending
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-21 19:55:14 +01:00
d4a162876a
feat: with noop
Some checks are pending
ci/woodpecker/pr/test Pipeline is pending
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-12 22:44:02 +01:00
835cd32fb1
feat: update deps
Some checks are pending
ci/woodpecker/pr/test Pipeline is pending
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-10 20:15:11 +01:00
9102ec4c1e
feat: update deps
Some checks are pending
ci/woodpecker/pr/test Pipeline is pending
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-10 20:05:07 +01:00
d746ef0dd7
feat: with more scopes
All checks were successful
ci/woodpecker/pr/test Pipeline was successful
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-04 17:37:53 +01:00
70915aec65
feat: change to more complete token instead of just id
All checks were successful
ci/woodpecker/pr/test Pipeline was successful
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-04 17:32:51 +01:00
74362f3b1c
feat: with migrate
All checks were successful
ci/woodpecker/pr/test Pipeline was successful
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-01 22:13:56 +01:00
21fc2587d4
feat: axum type 0.7
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-01 22:13:55 +01:00
c46bd34e16
feat: with actual return url
All checks were successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
Signed-off-by: kjuulh <contact@kjuulh.io>
2023-11-01 21:57:33 +01:00
22 changed files with 1585 additions and 765 deletions

2
.drone.yml Normal file
View File

@ -0,0 +1,2 @@
kind: template
load: cuddle-rust-lib-plan.yaml

View File

@ -1,34 +0,0 @@
when:
- event: [pull_request, tag]
- event: push
branch:
- main
variables:
- &rust_image 'rustlang/rust:nightly'
steps:
build:
image: *rust_image
group: ci
commands:
- "cargo build"
test:
image: *rust_image
group: ci
commands:
- "cargo test"
lint:
image: *rust_image
group: ci
commands:
- "cargo clippy"
fmt:
image: *rust_image
group: ci
commands:
- "cargo fmt --all --check"

1602
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,34 +5,40 @@ resolver = "2"
[workspace.dependencies] [workspace.dependencies]
nefarious-login = { path = "crates/nefarious-login" } nefarious-login = { path = "crates/nefarious-login" }
anyhow = { version = "1.0.75" } anyhow = { version = "1" }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tracing = { version = "0.1", features = ["log"] } tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = {version = "4.4.7", features = ["derive", "env"]} clap = { version = "4", features = ["derive", "env"] }
async-trait = {version = "0.1.74", features = []} async-trait = { version = "0.1", features = [] }
axum = {version = "0.6.20", features = []} axum = { version = "0.7.1", features = [
axum-extra = {version = "0.8.0", features = ["cookie", "cookie-private"]} "macros",
axum-sessions = {version = "0.6.1", features = []} ] }
async-sqlx-session = {version = "0.4.0", features = ["pg"]} axum-extra = { version = "0.9.0", features = [
"cookie",
"cookie-private",
"typed-header",
] }
axum-sessions = { version = "0.6.1", features = [] }
async-sqlx-session = { version = "0.4.0", features = ["pg"] }
serde = {version = "1.0", features = ["derive"]} serde = { version = "1", features = ["derive"] }
serde_json = {version = "1.0.108"} serde_json = { version = "1" }
uuid = {version = "1.5.0", features = []} uuid = {version = "1.5.0", features = ["v4"]}
sqlx = { version = "0.7.2", features = [ sqlx = { version = "0.7", features = [
"runtime-tokio-rustls", "runtime-tokio",
"postgres", "postgres",
"migrate", "migrate",
] } ] }
zitadel = { version = "3.4.29", features = ["axum"] } zitadel = { version = "3.4", features = ["axum"] }
tower = "0.4.13" tower = "0.4"
tower-http = { version = "0.4.4", features = ["cors", "trace"] } tower-http = { version = "0.4", features = ["cors", "trace"] }
oauth2 = "4.4.2" oauth2 = "4.4.2"
openidconnect = "3.4.0" openidconnect = "3.4"
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"
sealed_test = "1.0.0" sealed_test = "1.0.0"

View File

@ -6,17 +6,22 @@ use axum::http::{header::SET_COOKIE, HeaderMap};
use oauth2::url::Url; use oauth2::url::Url;
use crate::{ use crate::{
introspection::IntrospectionService, introspection::{IdToken, IntrospectionService},
login::{auth_clap::AuthEngine, AuthClap}, login::{auth_clap::AuthEngine, config::ConfigClap, AuthClap},
oauth::{zitadel::ZitadelConfig, OAuth}, oauth::{zitadel::ZitadelConfig, OAuth},
session::{SessionService, User}, session::{AppSession, SessionService, User},
}; };
#[async_trait] #[async_trait]
pub trait Auth { pub trait Auth {
async fn login(&self) -> anyhow::Result<Url>; async fn login(&self, return_url: Option<String>) -> anyhow::Result<(HeaderMap, Url)>;
async fn login_token(&self, user: &str, password: &str) -> anyhow::Result<String>; async fn login_token(&self, user: &str, password: &str) -> anyhow::Result<IdToken>;
async fn login_authorized(&self, code: &str, state: &str) -> anyhow::Result<(HeaderMap, Url)>; async fn login_authorized(
&self,
code: &str,
state: &str,
app_session_cookie: Option<String>,
) -> anyhow::Result<(HeaderMap, Url)>;
async fn get_user_from_session(&self, cookie: &str) -> anyhow::Result<User>; async fn get_user_from_session(&self, cookie: &str) -> anyhow::Result<User>;
} }
@ -26,14 +31,23 @@ pub struct AuthService(Arc<dyn Auth + Send + Sync + 'static>);
impl AuthService { impl AuthService {
pub async fn new(config: &AuthClap) -> anyhow::Result<Self> { pub async fn new(config: &AuthClap) -> anyhow::Result<Self> {
match config.engine { match config.engine {
AuthEngine::Noop => Ok(Self::new_noop()), AuthEngine::Noop => {
let session = SessionService::new(config).await?;
Ok(Self::new_noop(session, &config.config))
}
AuthEngine::Zitadel => { AuthEngine::Zitadel => {
let session = SessionService::new(config).await?; let session = SessionService::new(config).await?;
let oauth: OAuth = ZitadelConfig::try_from(config.zitadel.clone())?.into(); let oauth: OAuth = ZitadelConfig::try_from(config.zitadel.clone())?.into();
let introspection: IntrospectionService = let introspection: IntrospectionService =
IntrospectionService::new_zitadel(config).await?; IntrospectionService::new_zitadel(config).await?;
Ok(Self::new_zitadel(oauth, introspection, session)) Ok(Self::new_zitadel(
oauth,
introspection,
session,
&config.config,
))
} }
} }
} }
@ -42,16 +56,21 @@ impl AuthService {
oauth: OAuth, oauth: OAuth,
introspection: IntrospectionService, introspection: IntrospectionService,
session: SessionService, session: SessionService,
config: &ConfigClap,
) -> Self { ) -> Self {
Self(Arc::new(ZitadelAuthService { Self(Arc::new(ZitadelAuthService {
oauth, oauth,
introspection, introspection,
session, session,
config: config.clone(),
})) }))
} }
pub fn new_noop() -> Self { pub fn new_noop(session: SessionService, config: &ConfigClap) -> Self {
Self(Arc::new(NoopAuthService {})) Self(Arc::new(NoopAuthService {
session,
config: config.clone(),
}))
} }
} }
@ -67,20 +86,110 @@ pub struct ZitadelAuthService {
oauth: OAuth, oauth: OAuth,
introspection: IntrospectionService, introspection: IntrospectionService,
session: SessionService, session: SessionService,
config: ConfigClap,
} }
pub static COOKIE_NAME: &str = "SESSION"; pub static COOKIE_NAME: &str = "SESSION";
pub static COOKIE_APP_SESSION_NAME: &str = "APP_SESSION";
#[async_trait] #[async_trait]
impl Auth for ZitadelAuthService { impl Auth for ZitadelAuthService {
async fn login(&self) -> anyhow::Result<Url> { async fn login(&self, return_url: Option<String>) -> anyhow::Result<(HeaderMap, Url)> {
let mut headers = HeaderMap::new();
if let Some(return_url) = return_url.clone() {
let cookie_value = self.session.insert(AppSession { return_url }).await?;
let cookie = format!(
"{}={}; SameSite=Lax; Path=/",
COOKIE_APP_SESSION_NAME, cookie_value
);
headers.insert(SET_COOKIE, cookie.parse().unwrap());
}
let authorize_url = self.oauth.authorize_url().await?; let authorize_url = self.oauth.authorize_url().await?;
Ok(authorize_url) Ok((headers, authorize_url))
} }
async fn login_authorized(&self, code: &str, _state: &str) -> anyhow::Result<(HeaderMap, Url)> { async fn login_authorized(
&self,
code: &str,
_state: &str,
app_session_cookie: Option<String>,
) -> anyhow::Result<(HeaderMap, Url)> {
let token = self.oauth.exchange(code).await?; let token = self.oauth.exchange(code).await?;
let user_id = self.introspection.get_id_token(token.as_str()).await?; let id_token = self.introspection.get_id_token(token.as_str()).await?;
let cookie_value = self.session.insert_user("user", user_id.as_str()).await?; let cookie_value = self.session.insert_user("user", id_token).await?;
let cookie = format!("{}={}; SameSite=Lax; Path=/", COOKIE_NAME, cookie_value);
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, cookie.parse().unwrap());
let mut return_url = self.config.return_url.clone();
if let Some(cookie) = app_session_cookie {
if let Some(session) = self.session.get(&cookie).await? {
if session.return_url.starts_with('/') {
let mut url = Url::parse(&return_url)?;
url.set_path(&session.return_url);
return_url = url.to_string();
} else {
return_url = session.return_url;
}
}
}
Ok((
headers,
Url::parse(&return_url)
.context("failed to parse login_authorized zitadel return url")?,
))
}
async fn login_token(&self, _user: &str, password: &str) -> anyhow::Result<IdToken> {
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(u),
None => Err(anyhow::anyhow!("failed to find user")),
}
}
}
pub struct NoopAuthService {
session: SessionService,
config: ConfigClap,
}
#[async_trait]
impl Auth for NoopAuthService {
async fn login(&self, return_url: Option<String>) -> anyhow::Result<(HeaderMap, Url)> {
let url = Url::parse(&format!(
"{}/auth/authorized?code=noop&state=noop",
self.config
.return_url
.rsplit_once('/')
.map(|(a, _)| a)
.unwrap()
))
.unwrap();
Ok((HeaderMap::new(), url))
}
async fn login_authorized(
&self,
_code: &str,
_state: &str,
_app_session_cookie: Option<String>,
) -> anyhow::Result<(HeaderMap, Url)> {
let cookie_value = self
.session
.insert_user(
"user",
IdToken {
sub: uuid::Uuid::new_v4().to_string(),
email: format!("{}@kjuulh.io", uuid::Uuid::new_v4()),
name: uuid::Uuid::new_v4().to_string(),
},
)
.await?;
let cookie = format!("{}={}; SameSite=Lax; Path=/", COOKIE_NAME, cookie_value); let cookie = format!("{}={}; SameSite=Lax; Path=/", COOKIE_NAME, cookie_value);
@ -89,41 +198,19 @@ impl Auth for ZitadelAuthService {
Ok(( Ok((
headers, headers,
Url::parse("http://localhost:3001/authed") Url::parse(&self.config.return_url)
.context("failed to parse login_authorized zitadel return url")?, .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 login_token(&self, _user: &str, _password: &str) -> anyhow::Result<IdToken> {
todo!()
} }
async fn get_user_from_session(&self, cookie: &str) -> anyhow::Result<User> { async fn get_user_from_session(&self, cookie: &str) -> anyhow::Result<User> {
match self.session.get_user(cookie).await? { match self.session.get_user(cookie).await? {
Some(u) => Ok(User { id: u }), Some(u) => Ok(u),
None => Err(anyhow::anyhow!("failed to find user")), 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

@ -1,25 +1,24 @@
use std::fmt::Display; use std::fmt::Display;
use axum::extract::{FromRef, FromRequestParts, Query, State}; 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::request::Parts;
use axum::http::StatusCode; use axum::http::{HeaderMap, StatusCode, Uri};
use axum::response::{ErrorResponse, IntoResponse, Redirect, Response};
use axum::response::{ErrorResponse, IntoResponse, Redirect};
use axum::routing::get; use axum::routing::get;
use axum::{async_trait, Json, RequestPartsExt, Router, TypedHeader}; use axum::{async_trait, Json, RequestPartsExt, Router};
use axum_extra::extract::CookieJar;
use axum_extra::headers::authorization::Basic;
use axum_extra::headers::{Authorization, Cookie};
use axum_extra::TypedHeader;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use crate::auth::AuthService; use crate::auth::{AuthService, COOKIE_APP_SESSION_NAME};
use crate::session::User; use crate::session::User;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ZitadelAuthParams { pub struct ZitadelAuthParams {
#[allow(dead_code)]
return_url: Option<String>, return_url: Option<String>,
} }
@ -52,9 +51,9 @@ where
pub async fn zitadel_auth( pub async fn zitadel_auth(
State(auth_service): State<AuthService>, State(auth_service): State<AuthService>,
) -> Result<impl IntoResponse, ErrorResponse> { ) -> Result<impl IntoResponse, ErrorResponse> {
let url = auth_service.login().await.into_response()?; let (headers, url) = auth_service.login(None).await.into_response()?;
Ok(Redirect::to(url.as_ref())) Ok((headers, Redirect::to(url.as_ref())))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -67,9 +66,14 @@ pub struct AuthRequest {
pub async fn login_authorized( pub async fn login_authorized(
Query(query): Query<AuthRequest>, Query(query): Query<AuthRequest>,
State(auth_service): State<AuthService>, State(auth_service): State<AuthService>,
cookie_jar: CookieJar,
) -> Result<impl IntoResponse, ErrorResponse> { ) -> Result<impl IntoResponse, ErrorResponse> {
let cookie_value = cookie_jar
.get(COOKIE_APP_SESSION_NAME)
.map(|c| c.value().to_string());
let (headers, url) = auth_service let (headers, url) = auth_service
.login_authorized(&query.code, &query.state) .login_authorized(&query.code, &query.state, cookie_value)
.await .await
.into_response()?; .into_response()?;
@ -93,13 +97,21 @@ pub struct UserFromSession {
pub static COOKIE_NAME: &str = "SESSION"; pub static COOKIE_NAME: &str = "SESSION";
pub struct AuthRedirect((HeaderMap, String));
impl IntoResponse for AuthRedirect {
fn into_response(self) -> Response {
(self.0 .0, Redirect::temporary(&self.0 .1.as_str())).into_response()
}
}
#[async_trait] #[async_trait]
impl<S> FromRequestParts<S> for UserFromSession impl<S> FromRequestParts<S> for UserFromSession
where where
AuthService: FromRef<S>, AuthService: FromRef<S>,
S: Send + Sync, S: Send + Sync,
{ {
type Rejection = (StatusCode, &'static str); type Rejection = AuthRedirect;
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 auth_service = AuthService::from_ref(state); let auth_service = AuthService::from_ref(state);
@ -110,43 +122,58 @@ where
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 token = auth_service let token = match auth_service
.login_token(basic.username(), basic.password()) .login_token(basic.username(), basic.password())
.await .await
.into_response() .into_response()
.map_err(|_| { {
( Ok(login) => login,
StatusCode::INTERNAL_SERVER_ERROR, Err(e) => {
"could not get token from basic", tracing::info!("did not find a basic login token, will trigger login");
) let (headers, url) = auth_service
})?; .login(Some(parts.uri.to_string()))
.await
.expect("to be able to request login");
return Err(AuthRedirect((headers, url.to_string())));
}
};
return Ok(UserFromSession { return Ok(UserFromSession {
user: User { id: token }, user: User {
id: token.sub,
email: token.email,
name: token.name,
},
}); });
} }
return Err(anyhow::anyhow!("No session was found")) tracing::info!("did not find a cookie, will trigger login");
.into_response() let (headers, url) = auth_service
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "did not find a cookie"))?; .login(Some(parts.uri.to_string()))
.await
.expect("to be able to request login");
return Err(AuthRedirect((headers, url.to_string())));
} }
let session_cookie = session_cookie.unwrap(); let session_cookie = session_cookie.unwrap();
// continue to decode the session cookie // continue to decode the session cookie
let user = auth_service let user = match auth_service
.get_user_from_session(session_cookie) .get_user_from_session(session_cookie)
.await .await
.into_response() .into_response()
.map_err(|_| { {
( Ok(user) => user,
StatusCode::INTERNAL_SERVER_ERROR, Err(_) => {
"failed to decode session cookie", tracing::info!("could not get user from session, will trigger login");
) let (headers, url) = auth_service
})?; .login(Some(parts.uri.to_string()))
.await
.expect("to be able to request login");
return Err(AuthRedirect((headers, url.to_string())));
}
};
Ok(UserFromSession { Ok(UserFromSession { user })
user: User { id: user.id },
})
} }
} }

View File

@ -4,6 +4,7 @@ use async_trait::async_trait;
use axum::extract::FromRef; use axum::extract::FromRef;
use oauth2::TokenIntrospectionResponse; use oauth2::TokenIntrospectionResponse;
use openidconnect::IntrospectionUrl; use openidconnect::IntrospectionUrl;
use serde::{Deserialize, Serialize};
use zitadel::{ use zitadel::{
axum::introspection::IntrospectionStateBuilderError, axum::introspection::IntrospectionStateBuilderError,
credentials::Application, credentials::Application,
@ -15,10 +16,17 @@ use zitadel::{
use crate::login::AuthClap; use crate::login::AuthClap;
#[derive(Clone, Serialize, Deserialize)]
pub struct IdToken {
pub sub: String,
pub email: String,
pub name: String,
}
#[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>; async fn get_id_token(&self, token: &str) -> anyhow::Result<IdToken>;
} }
pub struct IntrospectionService(Arc<dyn Introspection + Send + Sync + 'static>); pub struct IntrospectionService(Arc<dyn Introspection + Send + Sync + 'static>);
@ -61,7 +69,7 @@ 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> { async fn get_id_token(&self, token: &str) -> anyhow::Result<IdToken> {
let config = &self.state.config; let config = &self.state.config;
let res = introspect( let res = introspect(
&config.introspection_uri, &config.introspection_uri,
@ -71,10 +79,21 @@ impl Introspection for ZitadelIntrospection {
) )
.await?; .await?;
Ok(res let sub = res
.sub() .sub()
.ok_or(anyhow::anyhow!("could not find a userid (sub) in token"))? .ok_or(anyhow::anyhow!("could not find a userid (sub) in token"))?
.to_string()) .to_string();
let extra = res.extra_fields();
let email = extra.email.clone().ok_or(anyhow::anyhow!(
"could not find a email (scope email) in token"
))?;
let name = extra.name.clone().ok_or(anyhow::anyhow!(
"could not find a name (scope profile) in token"
))?;
Ok(IdToken { sub, email, name })
} }
} }

View File

@ -15,7 +15,7 @@ pub struct AuthConfigFile {
zitadel: Option<ZitadelClap>, zitadel: Option<ZitadelClap>,
} }
#[derive(clap::Args, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(clap::Args, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[group(requires_all = ["client_id", "client_secret", "redirect_url", "authority_url"])] #[group(requires_all = ["client_id", "client_secret", "redirect_url", "authority_url"])]
pub struct ZitadelClap { pub struct ZitadelClap {
#[arg(env = "ZITADEL_CLIENT_ID", long = "zitadel-client-id")] #[arg(env = "ZITADEL_CLIENT_ID", long = "zitadel-client-id")]

View File

@ -105,12 +105,15 @@ impl OAuthClient for ZitadelOAuthClient {
Ok(()) Ok(())
} }
async fn authorize_url(&self) -> anyhow::Result<Url> { async fn authorize_url(&self) -> anyhow::Result<Url> {
let (auth_url, _csrf_token) = self let req = self
.client .client
.authorize_url(CsrfToken::new_random) .authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("identify".to_string())) .add_scope(Scope::new("identify".to_string()))
.add_scope(Scope::new("openid".to_string())) .add_scope(Scope::new("openid".to_string()))
.url(); .add_scope(Scope::new("email".to_string()))
.add_scope(Scope::new("profile".to_string()));
let (auth_url, _csrf_token) = req.url();
Ok(auth_url) Ok(auth_url)
} }

View File

@ -11,7 +11,7 @@ use async_trait::async_trait;
use axum_sessions::async_session::{Session as AxumSession, SessionStore as AxumSessionStore}; use axum_sessions::async_session::{Session as AxumSession, SessionStore as AxumSessionStore};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::login::AuthClap; use crate::{introspection::IdToken, login::AuthClap};
#[derive(clap::Args, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(clap::Args, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionClap { pub struct SessionClap {
@ -19,7 +19,7 @@ pub struct SessionClap {
pub postgresql: PostgresqlSessionClap, pub postgresql: PostgresqlSessionClap,
} }
#[derive(clap::Args, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(clap::Args, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PostgresqlSessionClap { pub struct PostgresqlSessionClap {
#[arg(env = "SESSION_POSTGRES_CONN", long = "session-postgres-conn")] #[arg(env = "SESSION_POSTGRES_CONN", long = "session-postgres-conn")]
pub conn: Option<String>, pub conn: Option<String>,
@ -27,16 +27,17 @@ pub struct PostgresqlSessionClap {
#[async_trait] #[async_trait]
pub trait Session { pub trait Session {
async fn insert_user(&self, id: &str, user_id: &str) -> anyhow::Result<String>; async fn insert(&self, app_session: AppSession) -> anyhow::Result<String>;
async fn get_user(&self, cookie: &str) -> anyhow::Result<Option<String>>; async fn insert_user(&self, id: &str, id_token: IdToken) -> anyhow::Result<String>;
async fn get_user(&self, cookie: &str) -> anyhow::Result<Option<User>>;
async fn get(&self, cookie: &str) -> anyhow::Result<Option<AppSession>>;
} }
pub struct SessionService(Arc<dyn Session + Send + Sync + 'static>); pub struct SessionService(Arc<dyn Session + Send + Sync + 'static>);
impl SessionService { impl SessionService {
pub async fn new(config: &AuthClap) -> anyhow::Result<Self> { pub async fn new(config: &AuthClap) -> anyhow::Result<Self> {
match config.session_backend { match config.session_backend {
SessionBackend::InMemory => Ok(Self(Arc::new(InMemorySessionService {}))), SessionBackend::InMemory => Ok(Self(Arc::new(InMemorySessionService::default()))),
SessionBackend::Postgresql => { SessionBackend::Postgresql => {
let postgres_session = PostgresSessionStore::new( let postgres_session = PostgresSessionStore::new(
config config
@ -48,6 +49,8 @@ impl SessionService {
) )
.await?; .await?;
postgres_session.migrate().await?;
Ok(Self(Arc::new(PostgresSessionService { Ok(Self(Arc::new(PostgresSessionService {
store: postgres_session, store: postgres_session,
}))) })))
@ -71,16 +74,38 @@ pub struct PostgresSessionService {
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User { pub struct User {
pub id: String, pub id: String,
pub email: String,
pub name: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AppSession {
pub return_url: String,
} }
#[async_trait] #[async_trait]
impl Session for PostgresSessionService { impl Session for PostgresSessionService {
async fn insert_user(&self, _id: &str, user_id: &str) -> anyhow::Result<String> { async fn insert(&self, app_session: AppSession) -> anyhow::Result<String> {
let mut session = AxumSession::new();
session.insert("app_session", app_session)?;
let cookie = self
.store
.store_session(session)
.await?
.ok_or(anyhow::anyhow!("failed to store app session"))?;
Ok(cookie)
}
async fn insert_user(&self, _id: &str, id_token: IdToken) -> anyhow::Result<String> {
let mut session = AxumSession::new(); let mut session = AxumSession::new();
session.insert( session.insert(
"user", "user",
User { User {
id: user_id.to_string(), id: id_token.sub,
email: id_token.email,
name: id_token.name,
}, },
)?; )?;
@ -92,14 +117,14 @@ impl Session for PostgresSessionService {
Ok(cookie) Ok(cookie)
} }
async fn get_user(&self, cookie: &str) -> anyhow::Result<Option<String>> { async fn get_user(&self, cookie: &str) -> anyhow::Result<Option<User>> {
if let Some(session) = self.store.load_session(cookie.to_string()).await.unwrap() { if let Some(session) = self.store.load_session(cookie.to_string()).await.unwrap() {
if let Some(user) = session.get::<User>("user") { if let Some(user) = session.get::<User>("user") {
tracing::debug!( tracing::debug!(
"UserFromSession: session decoded success, user_id={:?}", "UserFromSession: session decoded success, user_id={:?}",
user.id user.id
); );
Ok(Some(user.id)) Ok(Some(user))
} else { } else {
Ok(None) Ok(None)
} }
@ -111,17 +136,50 @@ impl Session for PostgresSessionService {
Err(anyhow::anyhow!("No session found for cookie")) Err(anyhow::anyhow!("No session found for cookie"))
} }
} }
async fn get(&self, cookie: &str) -> anyhow::Result<Option<AppSession>> {
let Some(session) = self.store.load_session(cookie.to_string()).await? else {
return Ok(None);
};
let Some(session) = session.get::<AppSession>("app_session") else {
anyhow::bail!("failed to deserialize app_session from cookie");
};
Ok(Some(session))
}
} }
pub struct InMemorySessionService {} #[derive(Default)]
pub struct InMemorySessionService {
store: std::sync::Mutex<std::collections::BTreeMap<String, User>>,
}
#[async_trait] #[async_trait]
impl Session for InMemorySessionService { impl Session for InMemorySessionService {
async fn insert_user(&self, _id: &str, _user_id: &str) -> anyhow::Result<String> { async fn insert(&self, app_session: AppSession) -> anyhow::Result<String> {
todo!() todo!()
} }
async fn insert_user(&self, _id: &str, id_token: IdToken) -> anyhow::Result<String> {
let user = User {
id: id_token.sub,
email: id_token.email,
name: id_token.name,
};
async fn get_user(&self, _cookie: &str) -> anyhow::Result<Option<String>> { let id = uuid::Uuid::new_v4();
self.store.lock().unwrap().insert(id.to_string(), user);
Ok(id.to_string())
}
async fn get_user(&self, cookie: &str) -> anyhow::Result<Option<User>> {
let user = self.store.lock().unwrap().get(cookie).cloned();
Ok(user)
}
async fn get(&self, cookie: &str) -> anyhow::Result<Option<AppSession>> {
todo!() todo!()
} }
} }

View File

@ -5,3 +5,9 @@ base: "git@git.front.kjuulh.io:kjuulh/cuddle-base.git"
vars: vars:
service: "nefarious-login" service: "nefarious-login"
registry: kasperhermansen registry: kasperhermansen
scripts:
local_up:
type: shell
local_down:
type: shell

View File

@ -1,4 +1,4 @@
use std::net::SocketAddr; use std::{net::SocketAddr, str::FromStr};
use axum::{ use axum::{
extract::{FromRef, State}, extract::{FromRef, State},
@ -60,13 +60,10 @@ async fn main() -> anyhow::Result<()> {
.with_state(state) .with_state(state)
.nest("/auth", AuthController::new_router(auth_service).await?); .nest("/auth", AuthController::new_router(auth_service).await?);
let addr = SocketAddr::from(([127, 0, 0, 1], 3001)); let addr = SocketAddr::from_str(&format!("{}:{}", "127.0.0.1", "3000"))?;
println!("listening on: {addr}"); let listener = tokio::net::TcpListener::bind(&addr).await?;
println!("open browser at: http://localhost:3001/auth/zitadel");
axum::Server::bind(&addr) axum::serve(listener, app).await?;
.serve(app.into_make_service())
.await
.unwrap();
Ok(()) Ok(())
} }

View File

@ -1,4 +1,4 @@
use std::net::SocketAddr; use std::{net::SocketAddr, str::FromStr};
use axum::{ use axum::{
extract::{FromRef, State}, extract::{FromRef, State},
@ -58,10 +58,11 @@ async fn main() -> anyhow::Result<()> {
let addr = SocketAddr::from(([127, 0, 0, 1], 3001)); let addr = SocketAddr::from(([127, 0, 0, 1], 3001));
println!("listening on: {addr}"); println!("listening on: {addr}");
println!("open browser at: http://localhost:3001/auth/zitadel"); println!("open browser at: http://localhost:3001/auth/zitadel");
axum::Server::bind(&addr)
.serve(app.into_make_service()) let addr = SocketAddr::from_str(&format!("{}:{}", "127.0.0.1", "3000"))?;
.await let listener = tokio::net::TcpListener::bind(&addr).await?;
.unwrap();
axum::serve(listener, app).await?;
Ok(()) Ok(())
} }

View File

@ -0,0 +1,16 @@
[package]
name = "custom_redirect"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
nefarious-login.workspace = true
tokio.workspace = true
anyhow.workspace = true
axum.workspace = true
clap.workspace = true
tracing-subscriber.workspace = true

View File

@ -0,0 +1,94 @@
use std::{net::SocketAddr, str::FromStr};
use axum::{
extract::{FromRef, State},
response::{IntoResponse, Redirect},
routing::get,
Router,
};
use nefarious_login::{
auth::AuthService,
axum::{AuthController, UserFromSession},
login::{
auth_clap::{AuthEngine, ZitadelClap},
config::ConfigClap,
AuthClap,
},
session::{PostgresqlSessionClap, SessionBackend},
};
use tracing_subscriber::EnvFilter;
#[derive(Clone)]
struct AppState {
auth: AuthService,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let auth = AuthClap {
engine: AuthEngine::Zitadel,
session_backend: SessionBackend::Postgresql,
zitadel: ZitadelClap {
authority_url: Some("https://personal-wxuujs.zitadel.cloud".into()),
client_id: Some("237412977047895154@nefarious-test".into()),
client_secret: Some(
"rWwDi8gjNOyuMFKoOjNSlhjcVZ1B25wDh6HsDL27f0g2Hb0xGbvEf0WXFY2akOlL".into(),
),
redirect_url: Some("http://localhost:3001/auth/authorized".into()),
},
session: nefarious_login::session::SessionClap {
postgresql: PostgresqlSessionClap {
conn: Some("postgres://nefarious-test:somenotverysecurepassword@localhost:5432/nefarious-test".into()),
},
},
config: ConfigClap { return_url: "http://localhost:3001/authed".into() } // this normally has /authed
};
let auth_service = AuthService::new(&auth).await?;
let state = AppState {
auth: auth_service.clone(),
};
let app = Router::new()
.route("/unauthed", get(unauthed))
.route("/authed", get(authed))
.route("/login", get(login))
.with_state(state)
.nest("/auth", AuthController::new_router(auth_service).await?);
let addr = SocketAddr::from_str(&format!("{}:{}", "127.0.0.1", "3000"))?;
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
impl FromRef<AppState> for AuthService {
fn from_ref(input: &AppState) -> Self {
input.auth.clone()
}
}
async fn login(State(auth_service): State<AuthService>) -> impl IntoResponse {
let (headers, url) = auth_service.login(Some("/authed".into())).await.unwrap();
(headers, Redirect::to(url.as_ref()))
}
async fn unauthed() -> String {
"Hello Unauthorized User".into()
}
#[axum::debug_handler()]
async fn authed(
user: UserFromSession,
State(_auth_service): State<AuthService>,
) -> impl IntoResponse {
format!("Hello authorized user: {:?}", user.user.id)
}

16
examples/noop/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "noop"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
nefarious-login.workspace = true
tokio.workspace = true
anyhow.workspace = true
axum.workspace = true
clap.workspace = true
tracing-subscriber.workspace = true

84
examples/noop/src/main.rs Normal file
View File

@ -0,0 +1,84 @@
use std::{net::SocketAddr, str::FromStr};
use axum::{
extract::{FromRef, State},
response::IntoResponse,
routing::get,
Router,
};
use nefarious_login::{
auth::AuthService,
axum::{AuthController, UserFromSession},
login::{
auth_clap::{AuthEngine, ZitadelClap},
config::ConfigClap,
AuthClap,
},
session::{PostgresqlSessionClap, SessionBackend},
};
use tracing_subscriber::EnvFilter;
#[derive(Clone)]
struct AppState {
auth: AuthService,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let auth = AuthClap {
engine: AuthEngine::Noop,
session_backend: SessionBackend::InMemory,
zitadel: ZitadelClap {
..Default::default()
},
session: nefarious_login::session::SessionClap {
postgresql: PostgresqlSessionClap {
..Default::default()
},
},
config: ConfigClap {
return_url: "http://localhost:3000/authed".into(),
},
};
let auth_service = AuthService::new(&auth).await?;
let state = AppState {
auth: auth_service.clone(),
};
let app = Router::new()
.route("/unauthed", get(unauthed))
.route("/authed", get(authed))
.with_state(state)
.nest("/auth", AuthController::new_router(auth_service).await?);
let addr = SocketAddr::from_str(&format!("{}:{}", "127.0.0.1", "3000"))?;
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
impl FromRef<AppState> for AuthService {
fn from_ref(input: &AppState) -> Self {
input.auth.clone()
}
}
async fn unauthed() -> String {
"Hello Unauthorized User".into()
}
#[axum::debug_handler()]
async fn authed(
user: UserFromSession,
State(_auth_service): State<AuthService>,
) -> impl IntoResponse {
format!("Hello authorized user: {:?}", user.user.id)
}

7
scripts/local_down.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/bash
set -e
cuddle render_template --template-file $TMP/docker-compose.local_up.yml.tmpl --dest $TMP/docker-compose.local_up.yml
docker compose -f $TMP/docker-compose.local_up.yml down -v

9
scripts/local_up.sh Executable file
View File

@ -0,0 +1,9 @@
#!/bin/bash
set -e
cuddle render_template --template-file $TMP/docker-compose.local_up.yml.tmpl --dest $TMP/docker-compose.local_up.yml
docker compose -f $TMP/docker-compose.local_up.yml up -d --remove-orphans --build
sleep 3

View File

@ -0,0 +1,7 @@
target/
.git/
.cuddle/
scripts/
cuddle.yaml
local.sh
README.md

View File

@ -0,0 +1,17 @@
version: '3.7'
services:
db:
image: bitnami/postgresql:16
restart: always
environment:
- POSTGRESQL_USERNAME=nefarious-test
- POSTGRESQL_DATABASE=nefarious-test
- POSTGRESQL_PASSWORD=somenotverysecurepassword
ports:
- 5432:5432
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata: