mirror of https://github.com/synctv-org/synctv
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1222 lines
48 KiB
Rust
1222 lines
48 KiB
Rust
use axum::{
|
|
extract::{FromRequest, FromRequestParts, Request, State},
|
|
Json,
|
|
};
|
|
use serde::de::DeserializeOwned;
|
|
|
|
use super::{AppError, AppResult, AppState};
|
|
use synctv_api_common::impls::{ApiError, EndpointRateLimitCategory};
|
|
use synctv_proto::client::{
|
|
ConfirmEmailLoginRequest, ConfirmEmailRegistrationRequest, CreateGuestTokenRequest,
|
|
CreateGuestTokenResponse, FinishMfaPasskeyRequest, FinishOpaqueLoginRequest,
|
|
FinishOpaqueRegistrationRequest, FinishPasskeyLoginRequest, FinishPasskeyRegistrationRequest,
|
|
LoginResponse, LoginWithDirectPasswordRequest, LogoutResponse, RefreshTokenRequest,
|
|
RefreshTokenResponse, RegisterResponse, RegisterWithDirectPasswordRequest,
|
|
RequestEmailLoginRequest, RequestEmailLoginResponse, RequestEmailRegistrationRequest,
|
|
RequestEmailRegistrationResponse, RequestMfaEmailCodeRequest, RequestMfaEmailCodeResponse,
|
|
StartLoginRequest, StartLoginResponse, StartMfaPasskeyRequest, StartMfaPasskeyResponse,
|
|
StartOpaqueLoginRequest, StartOpaqueLoginResponse, StartOpaqueRegistrationRequest,
|
|
StartOpaqueRegistrationResponse, StartPasskeyLoginRequest, StartPasskeyLoginResponse,
|
|
StartPasskeyRegistrationRequest, StartPasskeyRegistrationResponse, VerifyMfaEmailCodeRequest,
|
|
VerifyMfaRecoveryCodeRequest, VerifyMfaTotpRequest,
|
|
};
|
|
|
|
/// Extract the real client IP from a request.
|
|
///
|
|
/// Only trusts `X-Forwarded-For` / `X-Real-IP` headers when the direct
|
|
/// connection comes from a configured trusted proxy. This prevents
|
|
/// attackers from forging their IP to bypass per-IP brute-force protection.
|
|
#[must_use]
|
|
pub fn extract_client_ip(
|
|
runtime_settings: &synctv_api_common::ApiRuntimeSettings,
|
|
socket_addr: std::net::SocketAddr,
|
|
headers: &axum::http::HeaderMap,
|
|
) -> Result<std::net::IpAddr, synctv_adapter::client_ip::ClientIpHeaderError> {
|
|
synctv_adapter::client_ip::extract_client_ip_from_headers(
|
|
|ip| runtime_settings.server.is_trusted_proxy(ip),
|
|
socket_addr.ip(),
|
|
headers,
|
|
)
|
|
}
|
|
|
|
async fn extract_auth_request(
|
|
state: &AppState,
|
|
request: Request,
|
|
) -> Result<(synctv_api_common::impls::RequestMetadata, Request), AppError> {
|
|
let (mut parts, body) = request.into_parts();
|
|
let request_meta =
|
|
super::middleware::RequestMetadata::from_request_parts(&mut parts, state).await?;
|
|
let request_meta = request_meta
|
|
.0
|
|
.with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT));
|
|
Ok((request_meta, Request::from_parts(parts, body)))
|
|
}
|
|
|
|
fn map_json_rejection(err: &axum::extract::rejection::JsonRejection) -> ApiError {
|
|
if err.status() == axum::http::StatusCode::PAYLOAD_TOO_LARGE {
|
|
ApiError::PayloadTooLarge("Request body exceeds the 64 KB limit".to_string())
|
|
} else {
|
|
ApiError::InvalidInput(err.body_text())
|
|
}
|
|
}
|
|
|
|
async fn parse_auth_json<T>(request: Request) -> Result<T, ApiError>
|
|
where
|
|
T: DeserializeOwned,
|
|
{
|
|
let Json(request) = Json::<T>::from_request(request, &())
|
|
.await
|
|
.map_err(|err| map_json_rejection(&err))?;
|
|
Ok(request)
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/opaque/registration/start",
|
|
tag = "Auth",
|
|
request_body = StartOpaqueRegistrationRequest,
|
|
responses(
|
|
(status = 200, description = "OPAQUE registration challenge created", body = StartOpaqueRegistrationResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn start_opaque_registration(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<StartOpaqueRegistrationResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let executor = state.shared_api_runtime.client_api.clone();
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = executor
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<StartOpaqueRegistrationRequest>(request).await?;
|
|
client_api
|
|
.start_opaque_registration_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/opaque/registration/finish",
|
|
tag = "Auth",
|
|
request_body = FinishOpaqueRegistrationRequest,
|
|
responses(
|
|
(status = 200, description = "OPAQUE registration succeeded", body = RegisterResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn finish_opaque_registration(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<RegisterResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let executor = state.shared_api_runtime.client_api.clone();
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = executor
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<FinishOpaqueRegistrationRequest>(request).await?;
|
|
client_api
|
|
.finish_opaque_registration_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/direct-password/register",
|
|
tag = "Auth",
|
|
request_body = RegisterWithDirectPasswordRequest,
|
|
responses(
|
|
(status = 200, description = "Direct password registration succeeded", body = RegisterResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn register_with_direct_password(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<RegisterResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let executor = state.shared_api_runtime.client_api.clone();
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = executor
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<RegisterWithDirectPasswordRequest>(request).await?;
|
|
client_api
|
|
.register_with_direct_password_with_control(
|
|
req,
|
|
client_ip,
|
|
Some(&request_control),
|
|
)
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/direct-password/login",
|
|
tag = "Auth",
|
|
request_body = LoginWithDirectPasswordRequest,
|
|
responses(
|
|
(status = 200, description = "Direct password login succeeded", body = LoginResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 401, description = "Invalid credentials", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn login_with_direct_password(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<LoginResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let executor = state.shared_api_runtime.client_api.clone();
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = executor
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<LoginWithDirectPasswordRequest>(request).await?;
|
|
client_api
|
|
.login_with_direct_password_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
/// Identify an account and return its currently usable primary login methods.
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/login/start",
|
|
tag = "Auth",
|
|
request_body = StartLoginRequest,
|
|
responses(
|
|
(status = 200, description = "Login session created", body = StartLoginResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn start_login(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<StartLoginResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let executor = state.shared_api_runtime.client_api.clone();
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = executor
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<StartLoginRequest>(request).await?;
|
|
client_api
|
|
.start_login_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
/// Confirm a passwordless email login token.
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/email/confirm",
|
|
tag = "Auth",
|
|
request_body = ConfirmEmailLoginRequest,
|
|
responses(
|
|
(status = 200, description = "Email login confirmed", body = LoginResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 401, description = "Invalid credentials", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn confirm_email_login(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<LoginResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let email_api = state.shared_api_runtime.email_api.clone();
|
|
let response = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<ConfirmEmailLoginRequest>(request).await?;
|
|
client_api
|
|
.confirm_email_login_with_control(
|
|
email_api.as_deref(),
|
|
req,
|
|
client_ip,
|
|
Some(&request_control),
|
|
)
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/guest-token",
|
|
tag = "Auth",
|
|
request_body = CreateGuestTokenRequest,
|
|
responses(
|
|
(status = 200, description = "Guest token issued for a public room", body = CreateGuestTokenResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 403, description = "Guest access is not allowed", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn create_guest_token(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<CreateGuestTokenResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<CreateGuestTokenRequest>(request).await?;
|
|
client_api
|
|
.create_guest_token_with_control(req, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
/// Start a two-step OPAQUE login. The request carries the client's OPAQUE
|
|
/// credential request, not a plaintext password.
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/opaque/login/start",
|
|
tag = "Auth",
|
|
request_body = StartOpaqueLoginRequest,
|
|
responses(
|
|
(status = 200, description = "OPAQUE login challenge created", body = StartOpaqueLoginResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn start_opaque_login(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<StartOpaqueLoginResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let executor = state.shared_api_runtime.client_api.clone();
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = executor
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<StartOpaqueLoginRequest>(request).await?;
|
|
client_api
|
|
.start_opaque_login_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
/// Finish a two-step OPAQUE login and issue tokens when the OPAQUE proof
|
|
/// verifies.
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/opaque/login/finish",
|
|
tag = "Auth",
|
|
request_body = FinishOpaqueLoginRequest,
|
|
responses(
|
|
(status = 200, description = "OPAQUE login succeeded", body = LoginResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 401, description = "Invalid credentials", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn finish_opaque_login(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<LoginResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let executor = state.shared_api_runtime.client_api.clone();
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = executor
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<FinishOpaqueLoginRequest>(request).await?;
|
|
client_api
|
|
.finish_opaque_login_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/passkeys/registration/start",
|
|
tag = "Auth",
|
|
request_body = StartPasskeyRegistrationRequest,
|
|
responses(
|
|
(status = 200, description = "Passkey registration challenge created", body = StartPasskeyRegistrationResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn start_passkey_registration(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<StartPasskeyRegistrationResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<StartPasskeyRegistrationRequest>(request).await?;
|
|
client_api
|
|
.start_passkey_registration_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/passkeys/registration/finish",
|
|
tag = "Auth",
|
|
request_body = FinishPasskeyRegistrationRequest,
|
|
responses(
|
|
(status = 200, description = "Passkey registration succeeded", body = RegisterResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 401, description = "Invalid credentials", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn finish_passkey_registration(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<RegisterResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<FinishPasskeyRegistrationRequest>(request).await?;
|
|
client_api
|
|
.finish_passkey_registration_with_control(
|
|
req,
|
|
client_ip,
|
|
Some(&request_control),
|
|
)
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/passkeys/login/start",
|
|
tag = "Auth",
|
|
request_body = StartPasskeyLoginRequest,
|
|
responses(
|
|
(status = 200, description = "Passkey login challenge created", body = StartPasskeyLoginResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn start_passkey_login(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<StartPasskeyLoginResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<StartPasskeyLoginRequest>(request).await?;
|
|
client_api
|
|
.start_passkey_login_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/passkeys/login/finish",
|
|
tag = "Auth",
|
|
request_body = FinishPasskeyLoginRequest,
|
|
responses(
|
|
(status = 200, description = "Passkey login succeeded", body = LoginResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 401, description = "Invalid credentials", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn finish_passkey_login(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<LoginResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<FinishPasskeyLoginRequest>(request).await?;
|
|
client_api
|
|
.finish_passkey_login_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
fn require_email_api(
|
|
state: &AppState,
|
|
) -> Result<std::sync::Arc<synctv_api_common::impls::EmailApiImpl>, ApiError> {
|
|
state.shared_api_runtime.email_api.clone().ok_or_else(|| {
|
|
ApiError::ServiceUnavailable(synctv_common::messages::EMAIL_SERVICE_UNAVAILABLE.to_string())
|
|
})
|
|
}
|
|
|
|
/// Request a passwordless email login code.
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/email/request",
|
|
tag = "Auth",
|
|
request_body = RequestEmailLoginRequest,
|
|
responses(
|
|
(status = 200, description = "Email login request accepted", body = RequestEmailLoginResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn request_email_login(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<RequestEmailLoginResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let state_for_request = state.clone();
|
|
let result = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<RequestEmailLoginRequest>(request).await?;
|
|
require_email_api(&state_for_request)?;
|
|
state_for_request
|
|
.shared_api_runtime
|
|
.client_api
|
|
.request_email_login_with_control(req, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(result))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/email/registration/request",
|
|
tag = "Auth",
|
|
request_body = RequestEmailRegistrationRequest,
|
|
responses(
|
|
(status = 200, description = "Email registration request accepted", body = RequestEmailRegistrationResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn request_email_registration(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<RequestEmailRegistrationResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let state_for_request = state.clone();
|
|
let result = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<RequestEmailRegistrationRequest>(request).await?;
|
|
let email_api = require_email_api(&state_for_request)?;
|
|
email_api
|
|
.request_email_registration_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(RequestEmailRegistrationResponse {
|
|
message: result.message,
|
|
}))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/email/registration/confirm",
|
|
tag = "Auth",
|
|
request_body = ConfirmEmailRegistrationRequest,
|
|
responses(
|
|
(status = 200, description = "Email registration confirmed", body = RegisterResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn confirm_email_registration(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<RegisterResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let executor = state.shared_api_runtime.client_api.clone();
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = executor
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<ConfirmEmailRegistrationRequest>(request).await?;
|
|
client_api
|
|
.confirm_email_registration_with_direct_password_with_control(
|
|
req,
|
|
client_ip,
|
|
Some(&request_control),
|
|
)
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/mfa/email/request",
|
|
tag = "Auth",
|
|
request_body = RequestMfaEmailCodeRequest,
|
|
responses(
|
|
(status = 200, description = "MFA email code request accepted", body = RequestMfaEmailCodeResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 503, description = "Email service unavailable", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn request_mfa_email_code(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<RequestMfaEmailCodeResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let state_for_request = state.clone();
|
|
let result = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<RequestMfaEmailCodeRequest>(request).await?;
|
|
let email_api = require_email_api(&state_for_request)?;
|
|
email_api
|
|
.request_mfa_email_code_response_with_control(req, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(result))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/mfa/email/verify",
|
|
tag = "Auth",
|
|
request_body = VerifyMfaEmailCodeRequest,
|
|
responses(
|
|
(status = 200, description = "MFA email code verified", body = LoginResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 401, description = "Invalid MFA challenge or code", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 503, description = "Email service unavailable", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn verify_mfa_email_code(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<LoginResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let state_for_request = state.clone();
|
|
let response = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<VerifyMfaEmailCodeRequest>(request).await?;
|
|
let email_api = require_email_api(&state_for_request)?;
|
|
let outcome = email_api
|
|
.verify_mfa_email_code_request_with_control(
|
|
req,
|
|
client_ip,
|
|
Some(&request_control),
|
|
)
|
|
.await?;
|
|
synctv_api_common::impls::client::login_outcome_to_proto(
|
|
outcome,
|
|
&state_for_request.shared_api_runtime.public_id_codec,
|
|
)
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/mfa/passkeys/start",
|
|
tag = "Auth",
|
|
request_body = StartMfaPasskeyRequest,
|
|
responses(
|
|
(status = 200, description = "MFA passkey challenge created", body = StartMfaPasskeyResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn start_mfa_passkey(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<StartMfaPasskeyResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |_request_control| async move {
|
|
let req = parse_auth_json::<StartMfaPasskeyRequest>(request).await?;
|
|
client_api.start_mfa_passkey_with_control(req).await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/mfa/passkeys/finish",
|
|
tag = "Auth",
|
|
request_body = FinishMfaPasskeyRequest,
|
|
responses(
|
|
(status = 200, description = "MFA passkey verified", body = LoginResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 401, description = "Invalid MFA challenge or credential", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn finish_mfa_passkey(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<LoginResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<FinishMfaPasskeyRequest>(request).await?;
|
|
client_api
|
|
.finish_mfa_passkey_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/mfa/totp/verify",
|
|
tag = "Auth",
|
|
request_body = VerifyMfaTotpRequest,
|
|
responses(
|
|
(status = 200, description = "TOTP MFA verified", body = LoginResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 401, description = "Invalid MFA challenge or authenticator code", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn verify_mfa_totp(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<LoginResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<VerifyMfaTotpRequest>(request).await?;
|
|
client_api
|
|
.verify_mfa_totp_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/mfa/recovery-code/verify",
|
|
tag = "Auth",
|
|
request_body = VerifyMfaRecoveryCodeRequest,
|
|
responses(
|
|
(status = 200, description = "Recovery-code MFA verified", body = LoginResponse),
|
|
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 401, description = "Invalid MFA challenge or recovery code", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn verify_mfa_recovery_code(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<LoginResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let client_ip = request_meta.client_ip;
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = state
|
|
.shared_api_runtime
|
|
.client_api
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<VerifyMfaRecoveryCodeRequest>(request).await?;
|
|
client_api
|
|
.verify_mfa_recovery_code_with_control(req, client_ip, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
Ok(Json(response))
|
|
}
|
|
|
|
/// Refresh access token using refresh token.
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/auth/refresh",
|
|
tag = "Auth",
|
|
request_body = RefreshTokenRequest,
|
|
responses(
|
|
(status = 200, description = "Token refresh succeeded", body = RefreshTokenResponse),
|
|
(status = 401, description = "Invalid refresh token", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 413, description = "Request body exceeds the 64 KB limit", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 429, description = "Rate limited", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn refresh_token(
|
|
State(state): State<AppState>,
|
|
request: Request,
|
|
) -> AppResult<Json<RefreshTokenResponse>> {
|
|
let (request_meta, request) = extract_auth_request(&state, request).await?;
|
|
let executor = state.shared_api_runtime.client_api.clone();
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
let response = executor
|
|
.execute_public_endpoint_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control| async move {
|
|
let req = parse_auth_json::<RefreshTokenRequest>(request).await?;
|
|
client_api
|
|
.refresh_token_with_control(req, Some(&request_control))
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
/// Logout: blacklist the current access token.
|
|
///
|
|
/// Requires a valid Bearer token in the Authorization header. The token's JTI
|
|
/// is added to the blacklist with its remaining TTL so it cannot be reused.
|
|
///
|
|
/// Returns 400 Bad Request if no Bearer token is provided.
|
|
/// Returns 200 OK with success: true on successful logout.
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/user/logout",
|
|
tag = "Auth",
|
|
responses(
|
|
(status = 200, description = "Logout succeeded", body = LogoutResponse),
|
|
(status = 401, description = "Missing or invalid bearer token", body = crate::openapi::GoogleRpcStatusSchema)
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
)
|
|
)
|
|
)]
|
|
pub async fn logout(
|
|
State(state): State<AppState>,
|
|
request_meta: super::middleware::RequestMetadata,
|
|
) -> AppResult<Json<LogoutResponse>> {
|
|
let request_meta = request_meta
|
|
.0
|
|
.with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT));
|
|
let authorization = request_meta.authorization.clone();
|
|
let executor = state.shared_api_runtime.client_api.clone();
|
|
let client_api = state.shared_api_runtime.client_api.clone();
|
|
executor
|
|
.execute_user_endpoint(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Write,
|
|
|_| async move {
|
|
let auth_value = authorization.as_deref().ok_or_else(|| {
|
|
ApiError::Authentication(
|
|
synctv_common::messages::AUTHENTICATION_REQUIRED.to_string(),
|
|
)
|
|
})?;
|
|
let token = synctv_core::service::JwtValidator::extract_bearer_token(auth_value)
|
|
.map_err(|_| {
|
|
ApiError::Authentication(
|
|
synctv_common::messages::INVALID_OR_EXPIRED_TOKEN.to_string(),
|
|
)
|
|
})?;
|
|
client_api.logout(&token).await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(super::error::map_api_error)?;
|
|
|
|
Ok(Json(LogoutResponse {
|
|
success: true,
|
|
message: String::new(),
|
|
}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::{
|
|
body::{to_bytes, Body},
|
|
http::{Request, StatusCode},
|
|
response::IntoResponse,
|
|
routing::post,
|
|
Router,
|
|
};
|
|
use tower::ServiceExt;
|
|
|
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
|
|
|
#[tokio::test]
|
|
async fn oversized_auth_json_returns_structured_payload_too_large() -> TestResult {
|
|
async fn parse(request: Request<Body>) -> AppResult<impl IntoResponse> {
|
|
parse_auth_json::<serde_json::Value>(request)
|
|
.await
|
|
.map_err(AppError::from)?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
let app =
|
|
Router::new()
|
|
.route("/auth", post(parse))
|
|
.layer(axum::extract::DefaultBodyLimit::max(
|
|
super::super::body_limits::AUTH,
|
|
));
|
|
let body = format!(
|
|
r#"{{"value":"{}"}}"#,
|
|
"x".repeat(super::super::body_limits::AUTH)
|
|
);
|
|
let response = app
|
|
.oneshot(
|
|
Request::post("/auth")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(body))?,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
|
let body = to_bytes(response.into_body(), 64 * 1024).await?;
|
|
let json: serde_json::Value = serde_json::from_slice(&body)?;
|
|
assert_eq!(json["code"], tonic::Code::ResourceExhausted as i32);
|
|
assert_eq!(json["message"], "Request body exceeds the 64 KB limit");
|
|
assert!(json["details"].as_array().is_some_and(|details| {
|
|
details.iter().any(|detail| {
|
|
detail["@type"] == "type.googleapis.com/google.rpc.ErrorInfo"
|
|
&& detail["reason"] == "PAYLOAD_TOO_LARGE"
|
|
})
|
|
}));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_passkey_http_response_serializes_options_object() -> TestResult {
|
|
let response = StartPasskeyLoginResponse {
|
|
session_id: "session".to_string(),
|
|
options: Some(synctv_proto::client::PasskeyRequestChallenge {
|
|
public_key: Some(
|
|
synctv_proto::client::PasskeyPublicKeyCredentialRequestOptions {
|
|
challenge: b"abc".to_vec().into(),
|
|
timeout: None,
|
|
rp_id: "app.example.com".to_string(),
|
|
allow_credentials: Vec::new(),
|
|
user_verification:
|
|
synctv_proto::client::PasskeyUserVerificationRequirement::Required
|
|
as i32,
|
|
hints: Vec::new(),
|
|
extensions: None,
|
|
},
|
|
),
|
|
mediation: synctv_proto::client::PasskeyMediationRequirement::Unspecified as i32,
|
|
}),
|
|
};
|
|
|
|
let value = serde_json::to_value(response)?;
|
|
assert_eq!(value["sessionId"], "session");
|
|
assert!(value["options"].is_object());
|
|
assert_eq!(value["options"]["publicKey"]["challenge"], "YWJj");
|
|
assert_eq!(value["options"]["publicKey"]["rpId"], "app.example.com");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_passkey_http_finish_request_deserializes_credential_object() -> TestResult {
|
|
let request: FinishPasskeyLoginRequest = serde_json::from_str(
|
|
r#"{"sessionId":"session","credential":{"id":"cred","rawId":"cmF3","response":{"authenticatorData":"YXV0aA","clientDataJSON":"Y2xpZW50","signature":"c2ln"},"type":1}}"#,
|
|
)?;
|
|
|
|
assert_eq!(request.session_id, "session");
|
|
let credential = request.credential.expect("credential should deserialize");
|
|
assert_eq!(credential.id, "cred");
|
|
assert_eq!(
|
|
credential.r#type,
|
|
synctv_proto::client::PasskeyPublicKeyCredentialType::PublicKey as i32
|
|
);
|
|
assert_eq!(credential.raw_id.as_ref(), b"raw");
|
|
Ok(())
|
|
}
|
|
}
|