aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorrtkay123 <dev@kanjala.com>2026-02-09 19:33:04 +0200
committerrtkay123 <dev@kanjala.com>2026-02-09 19:33:04 +0200
commit375da0e07f2b3e88c2f6db0e6f4565b3ad555b95 (patch)
treedd7e302e7385c4e7cff5021178f127c693bede9d
parenta9630ecdc459068ca51ee2d7be3837d609840842 (diff)
downloadsellershut-375da0e07f2b3e88c2f6db0e6f4565b3ad555b95.tar.bz2
sellershut-375da0e07f2b3e88c2f6db0e6f4565b3ad555b95.zip
feat(auth): route to provider
-rw-r--r--Cargo.lock18
-rw-r--r--Cargo.toml1
-rw-r--r--lib/auth-service/Cargo.toml2
-rw-r--r--lib/auth-service/src/client/mod.rs16
-rw-r--r--lib/auth-service/src/lib.rs4
-rw-r--r--sellershut/Cargo.toml4
-rw-r--r--sellershut/src/config/cli/mod.rs10
-rw-r--r--sellershut/src/config/mod.rs89
-rw-r--r--sellershut/src/main.rs2
-rw-r--r--sellershut/src/server/error.rs21
-rw-r--r--sellershut/src/server/middleware/mod.rs1
-rw-r--r--sellershut/src/server/middleware/request_id.rs26
-rw-r--r--sellershut/src/server/mod.rs69
-rw-r--r--sellershut/src/server/routes/auth/mod.rs93
-rw-r--r--sellershut/src/server/routes/mod.rs9
15 files changed, 301 insertions, 64 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 253825c..0034898 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Cargo.toml b/Cargo.toml
index 0a75b41..bbe0fa7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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();