feat: with return url
Some checks are pending
ci/woodpecker/pr/test Pipeline is pending

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
Kasper Juul Hermansen 2023-11-21 22:52:22 +01:00
parent 9510b8fc42
commit 92e435e080
Signed by: kjuulh
GPG Key ID: 9AA7BC13CE474394
7 changed files with 86 additions and 37 deletions

View File

@ -9,18 +9,18 @@ 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, return_url: Option<String>) -> 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( async fn login_authorized(
&self, &self,
code: &str, code: &str,
state: &str, state: &str,
return_url: Option<String>, app_session_cookie: Option<String>,
) -> anyhow::Result<(HeaderMap, Url)>; ) -> 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>;
} }
@ -89,19 +89,31 @@ 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, return_url: Option<String>) -> anyhow::Result<Url> { async fn login(&self, return_url: Option<String>) -> anyhow::Result<(HeaderMap, Url)> {
let authorize_url = self.oauth.authorize_url(return_url).await?; let mut headers = HeaderMap::new();
if let Some(return_url) = return_url.clone() {
let cookie_value = self.session.insert(AppSession { return_url }).await?;
Ok(authorize_url) 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?;
Ok((headers, authorize_url))
} }
async fn login_authorized( async fn login_authorized(
&self, &self,
code: &str, code: &str,
_state: &str, _state: &str,
return_path: Option<String>, app_session_cookie: Option<String>,
) -> anyhow::Result<(HeaderMap, Url)> { ) -> 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?;
@ -113,8 +125,16 @@ impl Auth for ZitadelAuthService {
headers.insert(SET_COOKIE, cookie.parse().unwrap()); headers.insert(SET_COOKIE, cookie.parse().unwrap());
let mut return_url = self.config.return_url.clone(); let mut return_url = self.config.return_url.clone();
if let Some(return_path) = return_path { if let Some(cookie) = app_session_cookie {
return_url.push_str(&format!("?returnPath={return_path}")); 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((
@ -141,7 +161,7 @@ pub struct NoopAuthService {
#[async_trait] #[async_trait]
impl Auth for NoopAuthService { impl Auth for NoopAuthService {
async fn login(&self, return_url: Option<String>) -> 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
@ -151,13 +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,
_return_url: Option<String>, _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

@ -7,13 +7,14 @@ use axum::response::{ErrorResponse, IntoResponse, Redirect};
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)]
@ -50,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(None).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)]
@ -60,16 +61,19 @@ pub async fn zitadel_auth(
pub struct AuthRequest { pub struct AuthRequest {
code: String, code: String,
state: String, state: String,
#[serde(alias = "returnUrl")]
return_url: Option<String>,
} }
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, query.return_url) .login_authorized(&query.code, &query.state, cookie_value)
.await .await
.into_response()?; .into_response()?;

View File

@ -31,7 +31,7 @@ impl Deref for OAuth {
#[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, return_url: Option<String>) -> anyhow::Result<Url>; async fn authorize_url(&self) -> anyhow::Result<Url>;
async fn exchange(&self, code: &str) -> anyhow::Result<String>; async fn exchange(&self, code: &str) -> anyhow::Result<String>;
} }

View File

@ -10,7 +10,7 @@ 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, return_url: Option<String>) -> anyhow::Result<Url> { async fn authorize_url(&self) -> anyhow::Result<Url> {
Ok(Url::parse("http://localhost:3000/auth/zitadel").unwrap()) Ok(Url::parse("http://localhost:3000/auth/zitadel").unwrap())
} }

View File

@ -104,7 +104,7 @@ 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, return_url: Option<String>) -> anyhow::Result<Url> { async fn authorize_url(&self) -> anyhow::Result<Url> {
let req = self let req = self
.client .client
.authorize_url(CsrfToken::new_random) .authorize_url(CsrfToken::new_random)
@ -113,18 +113,6 @@ impl OAuthClient for ZitadelOAuthClient {
.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()));
let req = {
if let Some(return_url) = return_url {
let mut redirect_url = self.client.redirect_url().unwrap().as_str().to_string();
redirect_url.push_str(&format!("?returnUrl={}", return_url));
req.set_redirect_uri(std::borrow::Cow::Owned(RedirectUrl::new(redirect_url)?))
} else {
req
}
};
let (auth_url, _csrf_token) = req.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

@ -45,7 +45,7 @@ async fn main() -> anyhow::Result<()> {
conn: Some("postgres://nefarious-test:somenotverysecurepassword@localhost:5432/nefarious-test".into()), conn: Some("postgres://nefarious-test:somenotverysecurepassword@localhost:5432/nefarious-test".into()),
}, },
}, },
config: ConfigClap { return_url: "http://localhost:3001".into() } // this normally has /authed config: ConfigClap { return_url: "http://localhost:3001/authed".into() } // this normally has /authed
}; };
let auth_service = AuthService::new(&auth).await?; let auth_service = AuthService::new(&auth).await?;
@ -76,9 +76,9 @@ impl FromRef<AppState> for AuthService {
} }
async fn login(State(auth_service): State<AuthService>) -> impl IntoResponse { async fn login(State(auth_service): State<AuthService>) -> impl IntoResponse {
let url = auth_service.login(Some("/authed".into())).await.unwrap(); let (headers, url) = auth_service.login(Some("/authed".into())).await.unwrap();
Redirect::to(url.as_ref()) (headers, Redirect::to(url.as_ref()))
} }
async fn unauthed() -> String { async fn unauthed() -> String {