diff options
author | rtkay123 <dev@kanjala.com> | 2025-07-23 13:39:40 +0200 |
---|---|---|
committer | rtkay123 <dev@kanjala.com> | 2025-07-23 13:39:40 +0200 |
commit | 089efa225cc0a4e7be12608129ddbff28d11f320 (patch) | |
tree | d5d27ba5f5056c7a539365fd314e6d7ce7529523 /crates/auth/src | |
parent | 0a48abb0f0d4752b639fb89dd2db32a3db0eebb8 (diff) | |
download | sellershut-089efa225cc0a4e7be12608129ddbff28d11f320.tar.bz2 sellershut-089efa225cc0a4e7be12608129ddbff28d11f320.zip |
feat(auth): discord oauth
Diffstat (limited to 'crates/auth/src')
-rw-r--r-- | crates/auth/src/client.rs | 6 | ||||
-rw-r--r-- | crates/auth/src/client/discord.rs | 30 | ||||
-rw-r--r-- | crates/auth/src/cnfg.rs | 23 | ||||
-rw-r--r-- | crates/auth/src/error.rs | 26 | ||||
-rw-r--r-- | crates/auth/src/main.rs | 69 | ||||
-rw-r--r-- | crates/auth/src/server.rs | 28 | ||||
-rw-r--r-- | crates/auth/src/server/routes.rs | 47 | ||||
-rw-r--r-- | crates/auth/src/server/routes/authorised.rs | 23 | ||||
-rw-r--r-- | crates/auth/src/server/routes/discord.rs | 10 | ||||
-rw-r--r-- | crates/auth/src/server/routes/discord/discord_auth.rs | 20 | ||||
-rw-r--r-- | crates/auth/src/state.rs | 45 |
11 files changed, 325 insertions, 2 deletions
diff --git a/crates/auth/src/client.rs b/crates/auth/src/client.rs new file mode 100644 index 0000000..5aa4de0 --- /dev/null +++ b/crates/auth/src/client.rs @@ -0,0 +1,6 @@ +use oauth2::{EndpointNotSet, EndpointSet, basic::BasicClient}; + +pub mod discord; + +pub type OauthClient = + BasicClient<EndpointSet, EndpointNotSet, EndpointNotSet, EndpointNotSet, EndpointSet>; diff --git a/crates/auth/src/client/discord.rs b/crates/auth/src/client/discord.rs new file mode 100644 index 0000000..9217684 --- /dev/null +++ b/crates/auth/src/client/discord.rs @@ -0,0 +1,30 @@ +use crate::{client::OauthClient, cnfg::OauthCredentials, error::AppError}; +use anyhow::Context; +use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl, basic::BasicClient}; + +pub fn discord_client(config: &OauthCredentials) -> Result<OauthClient, AppError> { + let auth_url = config.auth_url.clone().unwrap_or_else(|| { + "https://discord.com/api/oauth2/authorize?response_type=code".to_string() + }); + + let token_url = config + .token_url + .clone() + .unwrap_or_else(|| "https://discord.com/api/oauth2/token".to_string()); + + let c = BasicClient::new(ClientId::new(config.client_id.to_owned())) + .set_client_secret(ClientSecret::new(config.client_secret.to_owned())) + .set_auth_uri( + AuthUrl::new(auth_url).context("failed to create new auth server url [discord]")?, + ) + .set_redirect_uri( + RedirectUrl::new(config.redirect_url.to_owned()) + .context("failed to create new redirect URL [discord]")?, + ) + .set_token_uri( + TokenUrl::new(token_url) + .context("failed to create new token endpoint URL [discord]")?, + ); + + Ok(c) +} diff --git a/crates/auth/src/cnfg.rs b/crates/auth/src/cnfg.rs new file mode 100644 index 0000000..6afe2f8 --- /dev/null +++ b/crates/auth/src/cnfg.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct LocalConfig { + pub oauth: OauthConfig, +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct OauthConfig { + pub discord: OauthCredentials, +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct OauthCredentials { + pub client_id: String, + pub client_secret: String, + pub redirect_url: String, + pub auth_url: Option<String>, + pub token_url: Option<String>, +} diff --git a/crates/auth/src/error.rs b/crates/auth/src/error.rs new file mode 100644 index 0000000..730f99a --- /dev/null +++ b/crates/auth/src/error.rs @@ -0,0 +1,26 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; + +#[derive(Debug)] +pub struct AppError(anyhow::Error); + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Something went wrong: {}", self.0), + ) + .into_response() + } +} + +impl<E> From<E> for AppError +where + E: Into<anyhow::Error>, +{ + fn from(err: E) -> Self { + Self(err.into()) + } +} diff --git a/crates/auth/src/main.rs b/crates/auth/src/main.rs index e7a11a9..4a71d69 100644 --- a/crates/auth/src/main.rs +++ b/crates/auth/src/main.rs @@ -1,3 +1,68 @@ -fn main() { - println!("Hello, world!"); +mod client; +mod cnfg; +mod error; +mod server; +mod state; + +use std::net::{Ipv6Addr, SocketAddr}; + +use clap::Parser; +use stack_up::{Configuration, Services, tracing::Tracing}; +use tracing::{info, trace}; + +use crate::{error::AppError, state::AppState}; + +/// auth-service +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + /// Path to config file + #[arg(short, long)] + config_file: Option<std::path::PathBuf>, +} + +#[tokio::main] +async fn main() -> Result<(), AppError> { + let args = Args::parse(); + let config = include_str!("../auth.toml"); + + let mut config = config::Config::builder() + .add_source(config::File::from_str(config, config::FileFormat::Toml)) + .add_source( + config::Environment::with_prefix("APP") + .separator("__") + .convert_case(config::Case::Kebab), + ); + + if let Some(cf) = args.config_file.as_ref().and_then(|v| v.to_str()) { + config = config.add_source(config::File::new(cf, config::FileFormat::Toml)); + }; + + let mut config: Configuration = config.build()?.try_deserialize()?; + dbg!(&config); + config.application.name = env!("CARGO_CRATE_NAME").into(); + config.application.version = env!("CARGO_PKG_VERSION").into(); + + let _tracing = Tracing::builder().build(&config.monitoring); + + let services = Services::builder() + .postgres(&config.database) + .await + .inspect_err(|e| tracing::error!("database: {e}"))? + .build(); + + trace!("running migrations"); + sqlx::migrate!("./migrations") + .run(&services.postgres) + .await?; + + let state = AppState::create(services, &config).await?; + + let addr = SocketAddr::from((Ipv6Addr::UNSPECIFIED, config.application.port)); + + let listener = tokio::net::TcpListener::bind(addr).await?; + info!(port = addr.port(), "serving api"); + + axum::serve(listener, server::router(state)).await?; + Ok(()) } diff --git a/crates/auth/src/server.rs b/crates/auth/src/server.rs new file mode 100644 index 0000000..3cfac60 --- /dev/null +++ b/crates/auth/src/server.rs @@ -0,0 +1,28 @@ +use axum::{Router, routing::get}; +use tower_http::trace::TraceLayer; + +use crate::{server::routes::health_check, state::AppHandle}; + +pub mod routes; + +pub fn router(state: AppHandle) -> Router { + Router::new() + .merge(routes::discord::discord_router(state.clone())) + .route("/", get(health_check)) + .route("/auth/authorised", get(health_check)) + .layer(TraceLayer::new_for_http()) +} + +#[cfg(test)] +pub(crate) fn test_config() -> stack_up::Configuration { + use stack_up::Configuration; + + let config_path = "auth.toml"; + + let config = config::Config::builder() + .add_source(config::File::new(config_path, config::FileFormat::Toml)) + .build() + .unwrap(); + + config.try_deserialize::<Configuration>().unwrap() +} diff --git a/crates/auth/src/server/routes.rs b/crates/auth/src/server/routes.rs new file mode 100644 index 0000000..7a25e70 --- /dev/null +++ b/crates/auth/src/server/routes.rs @@ -0,0 +1,47 @@ +pub mod authorised; +pub mod discord; +use axum::response::IntoResponse; +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Provider { + Discord, +} + +pub async fn health_check() -> impl IntoResponse { + let name = env!("CARGO_PKG_NAME"); + let ver = env!("CARGO_PKG_VERSION"); + + format!("{name} v{ver} is live") +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use sqlx::PgPool; + use stack_up::Services; + use tower::ServiceExt; + + use crate::{ + server::{self, test_config}, + state::AppState, + }; + + #[sqlx::test] + async fn health_check(pool: PgPool) { + let services = Services { postgres: pool }; + let state = AppState::create(services, &test_config()).await.unwrap(); + let app = server::router(state); + + let response = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } +} diff --git a/crates/auth/src/server/routes/authorised.rs b/crates/auth/src/server/routes/authorised.rs new file mode 100644 index 0000000..ddf048d --- /dev/null +++ b/crates/auth/src/server/routes/authorised.rs @@ -0,0 +1,23 @@ +use axum::{ + extract::{Query, State}, + response::IntoResponse, +}; +use axum_extra::{TypedHeader, headers}; +use serde::Deserialize; + +use crate::{error::AppError, server::routes::Provider, state::AppHandle}; + +#[derive(Debug, Deserialize)] +pub struct AuthRequest { + provider: Provider, + code: String, + state: String, +} + +async fn login_authorized( + Query(query): Query<AuthRequest>, + State(state): State<AppHandle>, + TypedHeader(cookies): TypedHeader<headers::Cookie>, +) -> Result<impl IntoResponse, AppError> { + Ok("") +} diff --git a/crates/auth/src/server/routes/discord.rs b/crates/auth/src/server/routes/discord.rs new file mode 100644 index 0000000..e1a834f --- /dev/null +++ b/crates/auth/src/server/routes/discord.rs @@ -0,0 +1,10 @@ +mod discord_auth; +use axum::{Router, routing::get}; + +use crate::state::AppHandle; + +pub fn discord_router(state: AppHandle) -> Router { + Router::new() + .route("/auth/discord", get(discord_auth::discord_auth)) + .with_state(state) +} diff --git a/crates/auth/src/server/routes/discord/discord_auth.rs b/crates/auth/src/server/routes/discord/discord_auth.rs new file mode 100644 index 0000000..b07fa7a --- /dev/null +++ b/crates/auth/src/server/routes/discord/discord_auth.rs @@ -0,0 +1,20 @@ +use axum::{ + extract::State, + http::HeaderMap, + response::{IntoResponse, Redirect}, +}; +use oauth2::{CsrfToken, Scope}; + +use crate::{error::AppError, state::AppHandle}; + +pub async fn discord_auth(State(state): State<AppHandle>) -> Result<impl IntoResponse, AppError> { + let (auth_url, csrf_token) = state + .discord_client + .authorize_url(CsrfToken::new_random) + .add_scope(Scope::new("identify".to_string())) + .url(); + + let mut headers = HeaderMap::new(); + + Ok((headers, Redirect::to(auth_url.as_ref()))) +} diff --git a/crates/auth/src/state.rs b/crates/auth/src/state.rs new file mode 100644 index 0000000..5a483c9 --- /dev/null +++ b/crates/auth/src/state.rs @@ -0,0 +1,45 @@ +use std::{ops::Deref, sync::Arc}; + +use stack_up::{Configuration, Services}; + +use crate::{ + client::{OauthClient, discord::discord_client}, + cnfg::LocalConfig, + error::AppError, +}; + +#[derive(Clone)] +pub struct AppHandle(Arc<AppState>); + +impl Deref for AppHandle { + type Target = Arc<AppState>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +pub struct AppState { + pub services: Services, + pub local_config: LocalConfig, + pub discord_client: OauthClient, + pub http_client: reqwest::Client, +} + +impl AppState { + pub async fn create( + services: Services, + configuration: &Configuration, + ) -> Result<AppHandle, AppError> { + let local_config: LocalConfig = serde_json::from_value(configuration.misc.clone())?; + + let discord_client = discord_client(&local_config.oauth.discord)?; + + Ok(AppHandle(Arc::new(Self { + services, + local_config, + discord_client, + http_client: reqwest::Client::new(), + }))) + } +} |