diff options
| author | rtkay123 <dev@kanjala.com> | 2026-02-09 19:33:04 +0200 |
|---|---|---|
| committer | rtkay123 <dev@kanjala.com> | 2026-02-09 19:33:04 +0200 |
| commit | 375da0e07f2b3e88c2f6db0e6f4565b3ad555b95 (patch) | |
| tree | dd7e302e7385c4e7cff5021178f127c693bede9d | |
| parent | a9630ecdc459068ca51ee2d7be3837d609840842 (diff) | |
| download | sellershut-375da0e07f2b3e88c2f6db0e6f4565b3ad555b95.tar.bz2 sellershut-375da0e07f2b3e88c2f6db0e6f4565b3ad555b95.zip | |
feat(auth): route to provider
| -rw-r--r-- | Cargo.lock | 18 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | lib/auth-service/Cargo.toml | 2 | ||||
| -rw-r--r-- | lib/auth-service/src/client/mod.rs | 16 | ||||
| -rw-r--r-- | lib/auth-service/src/lib.rs | 4 | ||||
| -rw-r--r-- | sellershut/Cargo.toml | 4 | ||||
| -rw-r--r-- | sellershut/src/config/cli/mod.rs | 10 | ||||
| -rw-r--r-- | sellershut/src/config/mod.rs | 89 | ||||
| -rw-r--r-- | sellershut/src/main.rs | 2 | ||||
| -rw-r--r-- | sellershut/src/server/error.rs | 21 | ||||
| -rw-r--r-- | sellershut/src/server/middleware/mod.rs | 1 | ||||
| -rw-r--r-- | sellershut/src/server/middleware/request_id.rs | 26 | ||||
| -rw-r--r-- | sellershut/src/server/mod.rs | 69 | ||||
| -rw-r--r-- | sellershut/src/server/routes/auth/mod.rs | 93 | ||||
| -rw-r--r-- | sellershut/src/server/routes/mod.rs | 9 |
15 files changed, 301 insertions, 64 deletions
@@ -2001,9 +2001,11 @@ name = "sellershut" version = "0.1.0" dependencies = [ "anyhow", + "async-session", "auth-service", "axum", "clap", + "dotenvy", "http-body-util", "secrecy", "serde", @@ -2013,6 +2015,7 @@ dependencies = [ "tokio", "toml", "tower", + "tower-http", "tracing", "tracing-appender", "tracing-subscriber", @@ -2023,6 +2026,7 @@ dependencies = [ "utoipa-redoc", "utoipa-scalar", "utoipa-swagger-ui", + "uuid", ] [[package]] @@ -2722,9 +2726,12 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", + "tokio", "tower", "tower-layer", "tower-service", + "tracing", + "uuid", ] [[package]] @@ -2980,6 +2987,17 @@ dependencies = [ ] [[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -8,6 +8,7 @@ readme = "README.md" documentation = "https://books.kanjala.com/sellershut" [workspace.dependencies] +async-session = "3.0.0" async-trait = "0.1.89" secrecy = "0.10.3" serde = "1.0.228" diff --git a/lib/auth-service/Cargo.toml b/lib/auth-service/Cargo.toml index d35d17e..c3b9be7 100644 --- a/lib/auth-service/Cargo.toml +++ b/lib/auth-service/Cargo.toml @@ -7,7 +7,7 @@ readme.workspace = true documentation.workspace = true [dependencies] -async-session = "3.0.0" +async-session.workspace = true async-trait.workspace = true oauth2 = "5.0.0" secrecy = "0.10.3" diff --git a/lib/auth-service/src/client/mod.rs b/lib/auth-service/src/client/mod.rs index 45e7e4d..45260fb 100644 --- a/lib/auth-service/src/client/mod.rs +++ b/lib/auth-service/src/client/mod.rs @@ -1,9 +1,12 @@ -use oauth2::{AuthUrl, ClientId, ClientSecret, EndpointNotSet, EndpointSet, RedirectUrl, TokenUrl}; +use oauth2::{ + AuthUrl, ClientId, ClientSecret, CsrfToken, EndpointNotSet, EndpointSet, RedirectUrl, Scope, + TokenUrl, +}; use secrecy::{ExposeSecret, SecretString}; use tracing::debug; use url::Url; -use crate::AuthServiceError; +use crate::{AuthServiceError, Provider}; #[derive(Debug, Clone)] pub struct OauthClient( @@ -16,6 +19,7 @@ pub struct OauthClient( >, ); +#[derive(Debug)] pub struct ClientConfig { client_id: String, client_secret: SecretString, @@ -63,4 +67,12 @@ impl OauthClient { .set_redirect_uri(RedirectUrl::from_url(url.to_owned())), ) } + + pub fn url_token(&self, provider: Provider) -> (Url, CsrfToken) { + let req = self.0.authorize_url(CsrfToken::new_random); + match provider { + Provider::Discord => req.add_scope(Scope::new("identify".to_string())), + } + .url() + } } diff --git a/lib/auth-service/src/lib.rs b/lib/auth-service/src/lib.rs index 308ce0f..0965f86 100644 --- a/lib/auth-service/src/lib.rs +++ b/lib/auth-service/src/lib.rs @@ -4,6 +4,10 @@ pub use service::*; use thiserror::Error; +pub enum Provider { + Discord, +} + #[derive(Error, Debug)] pub enum AuthServiceError { #[error("invalid url provided")] diff --git a/sellershut/Cargo.toml b/sellershut/Cargo.toml index 8ff0e79..74dfd5d 100644 --- a/sellershut/Cargo.toml +++ b/sellershut/Cargo.toml @@ -8,15 +8,18 @@ documentation.workspace = true [dependencies] anyhow = "1.0.101" +async-session.workspace = true auth-service = { path = "../lib/auth-service" } axum = "0.8.8" clap = { version = "4.5.57", features = ["derive", "env"] } +dotenvy = "0.15.7" secrecy = { workspace = true, features = ["serde"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true shared-svc.workspace = true sqlx.workspace = true toml = "0.9.11" +tower-http = { version = "0.6.8", features = ["cors", "request-id", "timeout", "trace"] } tracing.workspace = true tracing-appender = "0.2.4" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } @@ -27,6 +30,7 @@ utoipa-rapidoc = { version = "6.0.0", optional = true } utoipa-redoc = { version = "6.0.0", optional = true } utoipa-scalar = { version = "0.3.0", optional = true } utoipa-swagger-ui = { version = "9.0.2", optional = true } +uuid = { version = "1.20.0", features = ["v7"] } [dependencies.tokio] workspace = true diff --git a/sellershut/src/config/cli/mod.rs b/sellershut/src/config/cli/mod.rs index 784114e..00d51a1 100644 --- a/sellershut/src/config/cli/mod.rs +++ b/sellershut/src/config/cli/mod.rs @@ -11,7 +11,7 @@ use crate::config::cli::cache::Cache; use crate::config::cli::{database::Database, oauth::Oauth}; use crate::config::log_level::LogLevel; -#[derive(Parser, Deserialize, Default, Debug)] +#[derive(Parser, Deserialize, Debug)] /// A federated marketplace platform #[command(version, about, long_about = None)] #[serde(rename_all = "kebab-case")] @@ -21,13 +21,13 @@ pub struct Cli { #[serde(skip)] pub config: Option<PathBuf>, #[command(flatten)] - pub server: Option<Server>, + pub server: Server, #[command(flatten)] - pub oauth: Option<Oauth>, + pub oauth: Oauth, #[command(flatten)] - pub database: Option<Database>, + pub database: Database, #[command(flatten)] - pub cache: Option<Cache>, + pub cache: Cache, } #[derive(Parser, Deserialize, Default, Debug)] diff --git a/sellershut/src/config/mod.rs b/sellershut/src/config/mod.rs index 42d4d5d..3646b89 100644 --- a/sellershut/src/config/mod.rs +++ b/sellershut/src/config/mod.rs @@ -13,7 +13,7 @@ use crate::config::cli::{ cache::{RedisVariant, SentinelConfig, create_cache_variant}, }; -#[derive(Deserialize, ValueEnum, Clone, Copy)] +#[derive(Deserialize, Debug, ValueEnum, Clone, Copy)] #[serde(rename_all = "lowercase")] pub enum Environment { Development, @@ -29,6 +29,7 @@ impl From<CliEnvironment> for Environment { } } +#[derive(Debug)] pub struct Configuration { pub server: Server, pub oauth: Oauth, @@ -36,17 +37,20 @@ pub struct Configuration { pub cache: CacheConfig, } +#[derive(Debug)] pub struct Oauth { pub redirect_url: Url, pub discord: ClientConfig, } +#[derive(Debug)] pub struct Server { pub port: u16, pub environment: Environment, pub log_level: Level, } +#[derive(Debug)] pub struct Database { pub url: Url, pub pool_size: u32, @@ -55,11 +59,7 @@ pub struct Database { impl Configuration { pub fn merge(cli: &Cli, file: &Cli) -> anyhow::Result<Self> { let mut missing = Vec::new(); - let port = cli - .server - .as_ref() - .and_then(|value| value.port) - .or(file.server.as_ref().and_then(|value| value.port)); + let port = cli.server.port.or(file.server.port); if port.is_none() { missing.push("server.port"); @@ -67,32 +67,29 @@ impl Configuration { let log_level = cli .server - .as_ref() - .and_then(|v| v.log_level) - .or(file.server.as_ref().and_then(|v| v.log_level)) + .log_level + .or(file.server.log_level) .unwrap_or_default(); let environment = cli .server - .as_ref() - .and_then(|v| v.environment) - .or(file.server.as_ref().and_then(|v| v.environment)) + .environment + .or(file.server.environment) .unwrap_or_default() .into(); - let cli_oauth = cli.oauth.as_ref(); - let file_oauth = file.oauth.as_ref(); - - let oauth_redirect_url = cli_oauth - .and_then(|value| value.oauth_redirect_url.clone()) - .or(file_oauth.and_then(|value| value.oauth_redirect_url.clone())); + let oauth_redirect_url = cli + .oauth + .oauth_redirect_url + .clone() + .or(file.oauth.oauth_redirect_url.clone()); if oauth_redirect_url.is_none() { missing.push("oauth.redirect-url"); } - let cli_discord = cli_oauth.and_then(|v| v.discord.as_ref()); - let file_discord = file_oauth.and_then(|v| v.discord.as_ref()); + let cli_discord = cli.oauth.discord.as_ref(); + let file_discord = file.oauth.discord.as_ref(); let discord_client_id = cli_discord .and_then(|v| v.discord_client_id.clone()) @@ -123,50 +120,48 @@ impl Configuration { missing.push("oauth.discord.token-url"); } - let cli_db = cli.database.as_ref(); - let file_db = file.database.as_ref(); - - let database_url = cli_db - .and_then(|v| v.database_url.clone()) - .or(file_db.and_then(|v| v.database_url.clone())); + let database_url = cli + .database + .database_url + .as_ref() + .or(file.database.database_url.as_ref()); if database_url.is_none() { missing.push("database.url"); } - let pool_size = cli_db - .and_then(|v| v.database_pool_size) - .or(file_db.and_then(|v| v.database_pool_size)) + let pool_size = cli + .database + .database_pool_size + .or(file.database.database_pool_size) .unwrap_or(10); // sensible default - let cli_cache = cli.cache.as_ref(); - let file_cache = file.cache.as_ref(); - - let cache_url = cli_cache - .and_then(|v| v.cache_url.clone()) - .or(file_cache.and_then(|v| v.cache_url.clone())); + let cache_url = cli.cache.cache_url.clone().or(file.cache.cache_url.clone()); if cache_url.is_none() { missing.push("cache.url"); } - let cache_pooled = cli_cache - .and_then(|v| v.cache_pooled) - .or(file_cache.and_then(|v| v.cache_pooled)) + let cache_pooled = cli + .cache + .cache_pooled + .or(file.cache.cache_pooled) .unwrap_or(true); - let cache_kind = cli_cache - .and_then(|v| v.cache_kind) - .or(file_cache.and_then(|v| v.cache_kind)) + let cache_kind = cli + .cache + .cache_kind + .or(file.cache.cache_kind) .unwrap_or_default(); - let cache_max_connections = cli_cache - .and_then(|v| v.cache_max_connections) - .or(file_cache.and_then(|v| v.cache_max_connections)) + let cache_max_connections = cli + .cache + .cache_max_connections + .or(file.cache.cache_max_connections) .unwrap_or(10); // --- sentinel (only if kind == Sentinel) --- - let cli_sentinel = cli_cache.and_then(|v| v.sentinel.as_ref()); - let file_sentinel = file_cache.and_then(|v| v.sentinel.as_ref()); + let cli_sentinel = cli.cache.sentinel.as_ref(); + let file_sentinel = file.cache.sentinel.as_ref(); let sentinel = if cache_kind == RedisVariant::Sentinel { let service_name = cli_sentinel @@ -239,7 +234,7 @@ impl Configuration { discord: client_config, }, database: Database { - url: database_url.unwrap(), + url: database_url.unwrap().clone(), pool_size, }, cache: CacheConfig { diff --git a/sellershut/src/main.rs b/sellershut/src/main.rs index 9484f14..cf46a3f 100644 --- a/sellershut/src/main.rs +++ b/sellershut/src/main.rs @@ -19,6 +19,8 @@ use crate::state::AppState; #[tokio::main] async fn main() -> anyhow::Result<()> { + dotenvy::dotenv()?; + let cli = Cli::parse(); let config = if let Some(file) = cli.config.as_ref() { let contents = std::fs::read_to_string(file) diff --git a/sellershut/src/server/error.rs b/sellershut/src/server/error.rs new file mode 100644 index 0000000..93cad3e --- /dev/null +++ b/sellershut/src/server/error.rs @@ -0,0 +1,21 @@ +use axum::{http::StatusCode, response::IntoResponse}; +use tracing::error; + +#[derive(Debug)] +pub struct AppError(anyhow::Error); + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + error!("Application error: {:#}", self.0); + (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong").into_response() + } +} + +impl<E> From<E> for AppError +where + E: Into<anyhow::Error>, +{ + fn from(value: E) -> Self { + Self(value.into()) + } +} diff --git a/sellershut/src/server/middleware/mod.rs b/sellershut/src/server/middleware/mod.rs new file mode 100644 index 0000000..5df1059 --- /dev/null +++ b/sellershut/src/server/middleware/mod.rs @@ -0,0 +1 @@ +pub mod request_id; diff --git a/sellershut/src/server/middleware/request_id.rs b/sellershut/src/server/middleware/request_id.rs new file mode 100644 index 0000000..9e758d7 --- /dev/null +++ b/sellershut/src/server/middleware/request_id.rs @@ -0,0 +1,26 @@ +use axum::{ + extract::Request, + http::{HeaderValue, StatusCode}, + middleware::Next, + response::Response, +}; +use tracing::trace; +use uuid::Uuid; + +pub const REQUEST_ID_HEADER: &str = "x-request-id"; + +pub async fn middleware_request_id( + mut request: Request, + next: Next, +) -> Result<Response, StatusCode> { + let headers = request.headers_mut(); + let id = Uuid::now_v7().to_string(); + trace!(id = ?id, "attaching request id"); + let bytes = id.as_bytes(); + + headers.insert( + REQUEST_ID_HEADER, + HeaderValue::from_bytes(bytes).expect("valid id"), + ); + Ok(next.run(request).await) +} diff --git a/sellershut/src/server/mod.rs b/sellershut/src/server/mod.rs index 9ec4bf4..cc96c84 100644 --- a/sellershut/src/server/mod.rs +++ b/sellershut/src/server/mod.rs @@ -1,20 +1,41 @@ +pub mod error; +mod middleware; mod routes; pub mod shutdown; -use std::sync::Arc; - -use axum::Router; +use std::{sync::Arc, time::Duration}; + +use axum::{ + Router, + extract::MatchedPath, + http::{HeaderName, Request, StatusCode}, +}; +use tower_http::{ + cors::{self, CorsLayer}, + request_id::PropagateRequestIdLayer, + timeout::TimeoutLayer, + trace::TraceLayer, +}; +use tracing::info_span; use utoipa::OpenApi; use utoipa_axum::router::OpenApiRouter; -use crate::{server::routes::ApiDoc, state::AppState}; +use crate::{ + server::{ + middleware::request_id::{REQUEST_ID_HEADER, middleware_request_id}, + routes::{ApiDoc, auth::OauthDoc}, + }, + state::AppState, +}; pub async fn router(state: Arc<AppState>) -> Router<()> { - let doc = ApiDoc::openapi(); + let mut doc = ApiDoc::openapi(); - // doc.merge(other_doc); + doc.merge(OauthDoc::openapi()); - let stubs = OpenApiRouter::with_openapi(doc).routes(utoipa_axum::routes!(routes::health)); + let stubs = OpenApiRouter::with_openapi(doc) + .routes(utoipa_axum::routes!(routes::health)) + .routes(utoipa_axum::routes!(routes::auth::auth)); let (router, _api) = stubs.split_for_parts(); @@ -41,7 +62,39 @@ pub async fn router(state: Arc<AppState>) -> Router<()> { utoipa_rapidoc::RapiDoc::with_openapi("/api-docs/rapidoc.json", _api).path("/rapidoc"), ); - router.with_state(state) + router + .layer( + TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { + // Log the matched route's path (with placeholders not filled in). + // Use request.uri() or OriginalUri if you want the real path. + let matched_path = request + .extensions() + .get::<MatchedPath>() + .map(MatchedPath::as_str); + + info_span!( + "http_request", + method = ?request.method(), + matched_path, + some_other_field = tracing::field::Empty, + ) + }), + ) + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + Duration::from_secs(5), + )) + .layer(PropagateRequestIdLayer::new(HeaderName::from_static( + REQUEST_ID_HEADER, + ))) + .layer(axum::middleware::from_fn(middleware_request_id)) + .layer( + CorsLayer::new() + .allow_headers(cors::Any) + .allow_origin(cors::Any) + .allow_methods(cors::Any), + ) + .with_state(state) } #[cfg(test)] diff --git a/sellershut/src/server/routes/auth/mod.rs b/sellershut/src/server/routes/auth/mod.rs new file mode 100644 index 0000000..bfc045f --- /dev/null +++ b/sellershut/src/server/routes/auth/mod.rs @@ -0,0 +1,93 @@ +use std::sync::Arc; + +use anyhow::Context as _; +use async_session::{Session, SessionStore}; +use auth_service::Provider; +use axum::{ + extract::{Query, State}, + http::{HeaderMap, header::SET_COOKIE}, + response::{IntoResponse, Redirect}, +}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, OpenApi, ToSchema}; + +use crate::{server::error::AppError, state::AppState}; + +pub static COOKIE_NAME: &str = "SESSION"; +pub static CSRF_TOKEN: &str = "csrf_token"; +pub static OAUTH_PROVIDER: &str = "oauth_provider"; + +const AUTH: &str = "Authorisation"; + +#[derive(OpenApi)] +#[openapi(tags((name = AUTH, description = "Authorisation endpoints")), components(schemas(OauthProvider)))] +pub struct OauthDoc; + +#[derive(Deserialize, ToSchema, Clone, Debug, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum OauthProvider { + Discord, +} + +#[derive(Deserialize, Debug, Clone, Copy, IntoParams)] +#[into_params(parameter_in = Query)] +pub struct Params { + provider: OauthProvider, +} + +impl From<OauthProvider> for Provider { + fn from(value: OauthProvider) -> Self { + match value { + OauthProvider::Discord => Self::Discord, + } + } +} + +#[utoipa::path( + method(get), + path = "/auth", + params( + Params + ), + tag=AUTH, + responses( + ( + status = 302, + description = "Redirects to oauth provider for login", + headers( + ( + "set-cookie" = String, + description = "Session cookie" + ) + ) + ) + ) +)] +pub async fn auth( + Query(params): Query<Params>, + State(data): State<Arc<AppState>>, +) -> Result<impl IntoResponse, AppError> { + let (auth_url, csrf_token) = match params.provider { + OauthProvider::Discord => data.discord_client.url_token(params.provider.into()), + }; + + let mut session = Session::new(); + session.insert(CSRF_TOKEN, csrf_token)?; + session.insert(OAUTH_PROVIDER, params.provider)?; + + let cookie = data + .auth_service + .store_session(session) + .await? + .context("unexpected cookie value")?; + + let cookie = format!("{COOKIE_NAME}={cookie}; SameSite=Lax; HttpOnly; Secure; Path=/"); + + let mut headers = HeaderMap::new(); + headers.insert( + SET_COOKIE, + cookie.parse().context("failed to parse cookie")?, + ); + + Ok((headers, Redirect::to(auth_url.as_ref()))) +} diff --git a/sellershut/src/server/routes/mod.rs b/sellershut/src/server/routes/mod.rs index 287293a..66eb4e6 100644 --- a/sellershut/src/server/routes/mod.rs +++ b/sellershut/src/server/routes/mod.rs @@ -1,3 +1,5 @@ +pub(super) mod auth; + use axum::response::IntoResponse; use utoipa::OpenApi; @@ -56,7 +58,12 @@ mod tests { let app = router(state).await; let response = app - .oneshot(Request::builder().uri("/api/health").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/api/health") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); |
