10 Commits

Author SHA1 Message Date
dac6daf786 chore(deps): update tokio-tracing monorepo 2025-12-19 01:53:04 +00:00
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: https://git.front.kjuulh.io/kjuulh/nefarious-login/pulls/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
15 changed files with 1149 additions and 763 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"

1540
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,10 +13,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4", features = ["derive", "env"] }
async-trait = { version = "0.1", features = [] } async-trait = { version = "0.1", features = [] }
axum = { git = "https://github.com/tokio-rs/axum", branch = "main", features = [ axum = { version = "0.7.1", features = [
"macros", "macros",
] } ] }
axum-extra = { git = "https://github.com/tokio-rs/axum", branch = "main", features = [ axum-extra = { version = "0.9.0", features = [
"cookie", "cookie",
"cookie-private", "cookie-private",
"typed-header", "typed-header",

View File

@@ -9,14 +9,19 @@ use crate::{
introspection::{IdToken, IntrospectionService}, introspection::{IdToken, IntrospectionService},
login::{auth_clap::AuthEngine, config::ConfigClap, 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<IdToken>; 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>;
} }
@@ -84,15 +89,32 @@ pub struct ZitadelAuthService {
config: ConfigClap, 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 id_token = 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", id_token).await?; let cookie_value = self.session.insert_user("user", id_token).await?;
@@ -102,9 +124,22 @@ impl Auth for ZitadelAuthService {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, cookie.parse().unwrap()); 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(( Ok((
headers, headers,
Url::parse(&self.config.return_url) Url::parse(&return_url)
.context("failed to parse login_authorized zitadel return url")?, .context("failed to parse login_authorized zitadel return url")?,
)) ))
} }
@@ -126,7 +161,7 @@ pub struct NoopAuthService {
#[async_trait] #[async_trait]
impl Auth for NoopAuthService { impl Auth for NoopAuthService {
async fn login(&self) -> anyhow::Result<Url> { async fn login(&self, return_url: Option<String>) -> anyhow::Result<(HeaderMap, Url)> {
let url = Url::parse(&format!( let url = Url::parse(&format!(
"{}/auth/authorized?code=noop&state=noop", "{}/auth/authorized?code=noop&state=noop",
self.config self.config
@@ -136,12 +171,13 @@ impl Auth for NoopAuthService {
.unwrap() .unwrap()
)) ))
.unwrap(); .unwrap();
Ok(url) Ok((HeaderMap::new(), url))
} }
async fn login_authorized( async fn login_authorized(
&self, &self,
_code: &str, _code: &str,
_state: &str, _state: &str,
_app_session_cookie: Option<String>,
) -> anyhow::Result<(HeaderMap, Url)> { ) -> anyhow::Result<(HeaderMap, Url)> {
let cookie_value = self let cookie_value = self
.session .session

View File

@@ -2,23 +2,23 @@ use std::fmt::Display;
use axum::extract::{FromRef, FromRequestParts, Query, State}; use axum::extract::{FromRef, FromRequestParts, Query, State};
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}; use axum::response::{ErrorResponse, IntoResponse, Redirect, Response};
use axum::routing::get; use axum::routing::get;
use axum::{async_trait, Json, RequestPartsExt, Router}; use axum::{async_trait, Json, RequestPartsExt, Router};
use axum_extra::extract::CookieJar;
use axum_extra::headers::authorization::Basic; use axum_extra::headers::authorization::Basic;
use axum_extra::headers::{Authorization, Cookie}; use axum_extra::headers::{Authorization, Cookie};
use axum_extra::TypedHeader; 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>,
} }
@@ -51,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)]
@@ -66,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()?;
@@ -92,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);
@@ -109,16 +122,21 @@ 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 { user: User {
@@ -129,24 +147,32 @@ where
}); });
} }
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 { user }) Ok(UserFromSession { user })
} }

View File

@@ -105,14 +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()))
.add_scope(Scope::new("email".to_string())) .add_scope(Scope::new("email".to_string()))
.add_scope(Scope::new("profile".to_string())) .add_scope(Scope::new("profile".to_string()));
.url();
let (auth_url, _csrf_token) = req.url();
Ok(auth_url) Ok(auth_url)
} }

View File

@@ -27,12 +27,13 @@ pub struct PostgresqlSessionClap {
#[async_trait] #[async_trait]
pub trait Session { pub trait Session {
async fn insert(&self, app_session: AppSession) -> anyhow::Result<String>;
async fn insert_user(&self, id: &str, id_token: IdToken) -> anyhow::Result<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_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 {
@@ -77,8 +78,26 @@ pub struct User {
pub name: 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(&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> { 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(
@@ -117,6 +136,18 @@ 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))
}
} }
#[derive(Default)] #[derive(Default)]
@@ -126,6 +157,9 @@ pub struct InMemorySessionService {
#[async_trait] #[async_trait]
impl Session for InMemorySessionService { impl Session for InMemorySessionService {
async fn insert(&self, app_session: AppSession) -> anyhow::Result<String> {
todo!()
}
async fn insert_user(&self, _id: &str, id_token: IdToken) -> anyhow::Result<String> { async fn insert_user(&self, _id: &str, id_token: IdToken) -> anyhow::Result<String> {
let user = User { let user = User {
id: id_token.sub, id: id_token.sub,
@@ -145,4 +179,7 @@ impl Session for InMemorySessionService {
Ok(user) Ok(user)
} }
async fn get(&self, cookie: &str) -> anyhow::Result<Option<AppSession>> {
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

@@ -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)
}

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: