shoutdotdev/src/auth.rs

181 lines
5.5 KiB
Rust
Raw Normal View History

2025-02-26 13:10:50 -08:00
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};
2025-02-26 13:10:46 -08:00
use tracing::{debug, trace_span};
2025-02-26 13:10:50 -08:00
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)
}
}