181 lines
5.5 KiB
Rust
181 lines
5.5 KiB
Rust
|
use anyhow::{Context, Result};
|
||
|
use async_session::{Session, SessionStore as _};
|
||
|
use axum::{
|
||
|
extract::{FromRequestParts, Query, State},
|
||
|
http::request::Parts,
|
||
|
response::{IntoResponse, Redirect, Response},
|
||
|
routing::get,
|
||
|
RequestPartsExt, Router,
|
||
|
};
|
||
|
use axum_extra::{
|
||
|
extract::cookie::{Cookie, CookieJar, SameSite},
|
||
|
headers, TypedHeader,
|
||
|
};
|
||
|
use diesel::prelude::*;
|
||
|
use oauth2::{
|
||
|
basic::BasicClient, reqwest::async_http_client, AuthUrl, AuthorizationCode, ClientId,
|
||
|
ClientSecret, CsrfToken, RedirectUrl, TokenResponse, TokenUrl,
|
||
|
};
|
||
|
use serde::{Deserialize, Serialize};
|
||
|
use tracing::{debug, span, trace_span, Level};
|
||
|
|
||
|
use crate::{app_error::AppError, app_state::AppState, schema, settings::Settings};
|
||
|
|
||
|
pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient, AppError> {
|
||
|
Ok(BasicClient::new(
|
||
|
ClientId::new(settings.auth.client_id.clone()),
|
||
|
Some(ClientSecret::new(settings.auth.client_secret.clone())),
|
||
|
AuthUrl::new(settings.auth.auth_url.clone())
|
||
|
.context("failed to create new authorization server URL")?,
|
||
|
Some(
|
||
|
TokenUrl::new(settings.auth.token_url.clone())
|
||
|
.context("failed to create new token endpoint URL")?,
|
||
|
),
|
||
|
)
|
||
|
.set_redirect_uri(
|
||
|
RedirectUrl::new(settings.auth.redirect_url.clone())
|
||
|
.context("failed to create new redirection URL")?,
|
||
|
))
|
||
|
}
|
||
|
|
||
|
pub fn new_router() -> Router<AppState> {
|
||
|
Router::new()
|
||
|
.route("/login", get(propel_auth))
|
||
|
.route("/callback", get(login_authorized))
|
||
|
.route("/logout", get(logout))
|
||
|
}
|
||
|
|
||
|
pub async fn propel_auth(State(state): State<AppState>) -> impl IntoResponse {
|
||
|
let csrf_token = CsrfToken::new_random();
|
||
|
let (auth_url, _csrf_token) = state
|
||
|
.oauth_client
|
||
|
.authorize_url(|| csrf_token)
|
||
|
// .add_scopes(vec![Scope::new("openid".to_string())])
|
||
|
.url();
|
||
|
// FIXME: check CSRF token
|
||
|
Redirect::to(auth_url.as_ref())
|
||
|
}
|
||
|
|
||
|
pub async fn logout(
|
||
|
State(state): State<AppState>,
|
||
|
TypedHeader(cookies): TypedHeader<headers::Cookie>,
|
||
|
) -> Result<impl IntoResponse, AppError> {
|
||
|
let cookie = cookies
|
||
|
.get(state.settings.auth.cookie_name.as_str())
|
||
|
.context("couldn't get session cookie")?;
|
||
|
let session_id = Session::id_from_cookie_value(cookie)?;
|
||
|
state
|
||
|
.db_pool
|
||
|
.get()
|
||
|
.await?
|
||
|
.interact(move |conn| {
|
||
|
diesel::delete(schema::browser_sessions::table)
|
||
|
.filter(schema::browser_sessions::id.eq(session_id))
|
||
|
.execute(conn)
|
||
|
})
|
||
|
.await
|
||
|
.unwrap()?;
|
||
|
// FIXME: call logout endpoint of OIDC provider
|
||
|
Ok(Redirect::to(&state.settings.base_path))
|
||
|
}
|
||
|
|
||
|
#[derive(Debug, Deserialize)]
|
||
|
pub struct AuthRequestQuery {
|
||
|
code: String,
|
||
|
state: String, // CSRF token
|
||
|
}
|
||
|
|
||
|
pub const AUTH_INFO_SESSION_KEY: &'static str = "user";
|
||
|
|
||
|
#[derive(Debug, Deserialize, Serialize)]
|
||
|
pub struct AuthInfo {
|
||
|
pub sub: String,
|
||
|
pub email: String,
|
||
|
}
|
||
|
|
||
|
async fn get_user_info(
|
||
|
settings: &Settings,
|
||
|
access_token: &oauth2::AccessToken,
|
||
|
) -> Result<AuthInfo, AppError> {
|
||
|
let client = reqwest::Client::new();
|
||
|
Ok(client
|
||
|
.get(settings.auth.userinfo_url.as_str())
|
||
|
.bearer_auth(access_token.secret())
|
||
|
.send()
|
||
|
.await?
|
||
|
.json()
|
||
|
.await?)
|
||
|
}
|
||
|
|
||
|
pub async fn login_authorized(
|
||
|
Query(query): Query<AuthRequestQuery>,
|
||
|
State(state): State<AppState>,
|
||
|
jar: CookieJar,
|
||
|
) -> Result<impl IntoResponse, AppError> {
|
||
|
let response = state
|
||
|
.oauth_client
|
||
|
.exchange_code(AuthorizationCode::new(query.code.clone()))
|
||
|
.request_async(async_http_client)
|
||
|
.await?;
|
||
|
let user_info = get_user_info(&state.settings, response.access_token()).await?;
|
||
|
let mut session = Session::new();
|
||
|
session.insert(AUTH_INFO_SESSION_KEY, &user_info)?;
|
||
|
let cookie_value = state
|
||
|
.session_store
|
||
|
.store_session(session)
|
||
|
.await?
|
||
|
.context("cookie value from store_session() is None")?;
|
||
|
let jar = jar.add(
|
||
|
Cookie::build((state.settings.auth.cookie_name.clone(), cookie_value))
|
||
|
.same_site(SameSite::Lax)
|
||
|
.http_only(true)
|
||
|
.path("/"),
|
||
|
);
|
||
|
Ok((jar, Redirect::to(&format!("{}/", state.settings.base_path))))
|
||
|
}
|
||
|
|
||
|
pub struct AuthRedirect {
|
||
|
base_path: String,
|
||
|
}
|
||
|
|
||
|
impl AuthRedirect {
|
||
|
pub fn new(base_path: &str) -> Self {
|
||
|
Self {
|
||
|
base_path: base_path.to_string(),
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
impl IntoResponse for AuthRedirect {
|
||
|
fn into_response(self) -> Response {
|
||
|
Redirect::to(&format!("{}/auth/login", self.base_path)).into_response()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
impl FromRequestParts<AppState> for AuthInfo {
|
||
|
type Rejection = AuthRedirect;
|
||
|
|
||
|
async fn from_request_parts(
|
||
|
parts: &mut Parts,
|
||
|
state: &AppState,
|
||
|
) -> Result<Self, <Self as FromRequestParts<AppState>>::Rejection> {
|
||
|
let _ = trace_span!("AuthInfo from_request_parts()").enter();
|
||
|
let jar = parts.extract::<CookieJar>().await.unwrap();
|
||
|
let session_cookie = jar
|
||
|
.get(&state.settings.auth.cookie_name)
|
||
|
.ok_or(AuthRedirect::new(&state.settings.base_path))?;
|
||
|
debug!("session cookie loaded");
|
||
|
let session = state
|
||
|
.session_store
|
||
|
.load_session(session_cookie.value().to_string())
|
||
|
.await
|
||
|
.unwrap()
|
||
|
.ok_or(AuthRedirect::new(&state.settings.base_path))?;
|
||
|
debug!("session loaded");
|
||
|
let user = session
|
||
|
.get::<AuthInfo>(AUTH_INFO_SESSION_KEY)
|
||
|
.ok_or(AuthRedirect::new(&state.settings.base_path))?;
|
||
|
Ok(user)
|
||
|
}
|
||
|
}
|