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.
714 lines
24 KiB
Rust
714 lines
24 KiB
Rust
//! `OAuth2` HTTP handlers
|
|
//!
|
|
//! Provides `OAuth2` endpoints for browser/frontend-driven `OAuth2` flows.
|
|
//! Uses proto-generated types for request/response consistency with gRPC.
|
|
//!
|
|
//! ## HTTP vs gRPC endpoint mapping
|
|
//!
|
|
//! | HTTP endpoint | gRPC RPC | Auth required |
|
|
//! |--------------------------------------------------------|----------------------------------|---------------|
|
|
//! | `GET /api/oauth2/:provider/authorize?redirectUrl=` | `GetAuthorizationUrl` | No |
|
|
//! | `GET /api/oauth2/:provider/bind?redirectUrl=` | `GetAuthorizationUrlForBind` | Yes |
|
|
//! | `POST /api/oauth2/exchange` (JSON body) | `ExchangeAuthorizationCode` | No |
|
|
//! | `GET /api/oauth2/providers` | `ListAvailableProviders` | No |
|
|
//! | `DELETE /api/oauth2/type/:provider/unlink?providerUserId=`| `UnlinkProvider` | Yes |
|
|
//! | `GET /api/oauth2/linked` | `GetLinkedProviders` | Yes |
|
|
//!
|
|
//! Both transports share the same `OAuth2ApiImpl` backend. HTTP extracts the
|
|
//! provider name from URL path segments and optional params from query strings;
|
|
//! gRPC encodes everything in the request message. Error responses differ:
|
|
//! HTTP returns `AppError` JSON `{error, status}`, gRPC returns `tonic::Status`.
|
|
//!
|
|
//! See also: [`crate::grpc::oauth2_service`] for the gRPC implementation.
|
|
|
|
use axum::{
|
|
extract::{Path, Query, State},
|
|
http::HeaderMap,
|
|
Json,
|
|
};
|
|
use std::sync::Arc;
|
|
use tracing::{debug, error, info};
|
|
|
|
use synctv_proto::client::{
|
|
ExchangeAuthorizationCodeRequest, ExchangeAuthorizationCodeResponse,
|
|
GetAuthorizationUrlForBindRequest, GetAuthorizationUrlForBindResponse,
|
|
GetAuthorizationUrlRequest, GetAuthorizationUrlResponse, GetLinkedProvidersResponse,
|
|
ListAvailableProvidersResponse, OAuth2ProviderInstancePathRequest,
|
|
OAuth2ProviderTypePathRequest, UnlinkProviderRequest, UnlinkProviderResponse,
|
|
};
|
|
|
|
use super::{error::map_api_error, middleware::RequestMetadata, AppError, AppResult, AppState};
|
|
use synctv_api_common::impls::EndpointRateLimitCategory;
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
|
|
pub struct AuthorizationUrlQuery {
|
|
#[serde(default)]
|
|
redirect_url: Option<String>,
|
|
#[serde(default)]
|
|
native: Option<bool>,
|
|
}
|
|
|
|
impl AuthorizationUrlQuery {
|
|
fn into_request(self, provider: String) -> GetAuthorizationUrlRequest {
|
|
GetAuthorizationUrlRequest {
|
|
provider,
|
|
redirect_url: self.redirect_url,
|
|
native: self.native,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
|
|
pub struct BindAuthorizationUrlQuery {
|
|
#[serde(default)]
|
|
redirect_url: Option<String>,
|
|
#[serde(default)]
|
|
verification_id: String,
|
|
#[serde(default)]
|
|
native: Option<bool>,
|
|
}
|
|
|
|
impl BindAuthorizationUrlQuery {
|
|
fn into_request(self, provider: String) -> GetAuthorizationUrlForBindRequest {
|
|
GetAuthorizationUrlForBindRequest {
|
|
provider,
|
|
redirect_url: self.redirect_url,
|
|
verification_id: self.verification_id,
|
|
native: self.native,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
|
|
pub struct UnlinkProviderQuery {
|
|
#[serde(default)]
|
|
provider_user_id: String,
|
|
#[serde(default)]
|
|
verification_id: String,
|
|
#[serde(default)]
|
|
provider_instance_name: String,
|
|
}
|
|
|
|
impl UnlinkProviderQuery {
|
|
fn into_request(self, provider: i32) -> UnlinkProviderRequest {
|
|
UnlinkProviderRequest {
|
|
provider,
|
|
provider_user_id: self.provider_user_id,
|
|
verification_id: self.verification_id,
|
|
provider_instance_name: self.provider_instance_name,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn oauth2_unavailable_error() -> AppError {
|
|
AppError::new(
|
|
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
|
"OAuth2 is not available on this server.",
|
|
)
|
|
}
|
|
|
|
fn require_oauth2_api(
|
|
state: &AppState,
|
|
) -> Result<Arc<synctv_api_common::impls::OAuth2ApiImpl>, AppError> {
|
|
state
|
|
.shared_api_runtime
|
|
.oauth2_api
|
|
.clone()
|
|
.ok_or_else(oauth2_unavailable_error)
|
|
}
|
|
|
|
fn oauth2_provider_type_path_to_proto(provider: &str) -> Result<i32, AppError> {
|
|
synctv_api_common::impls::validate_proto_request(&OAuth2ProviderTypePathRequest {
|
|
provider: provider.to_string(),
|
|
})
|
|
.map_err(map_api_error)?;
|
|
synctv_api_common::impls::OAuth2ApiImpl::oauth2_provider_name_to_proto(provider)
|
|
.map_err(map_api_error)
|
|
}
|
|
|
|
fn map_oauth2_exchange_error(error: synctv_api_common::impls::ApiError) -> AppError {
|
|
map_api_error(error)
|
|
}
|
|
|
|
fn request_allowed_web_callback(
|
|
redirect_url: Option<&str>,
|
|
native: Option<bool>,
|
|
headers: &HeaderMap,
|
|
direct_peer_ip: Option<std::net::IpAddr>,
|
|
server: &synctv_api_common::ApiServerSettings,
|
|
) -> AppResult<Option<String>> {
|
|
let Some(redirect_url) = redirect_url.filter(|_| native != Some(true)) else {
|
|
return Ok(None);
|
|
};
|
|
let Ok(parsed) = url::Url::parse(redirect_url) else {
|
|
return Ok(None);
|
|
};
|
|
if parsed.path() != "/oauth2/callback"
|
|
|| parsed.query().is_some()
|
|
|| parsed.fragment().is_some()
|
|
|| !parsed.username().is_empty()
|
|
|| parsed.password().is_some()
|
|
{
|
|
return Ok(None);
|
|
}
|
|
|
|
let request_scheme = if direct_peer_ip.is_some_and(|ip| server.is_trusted_proxy(&ip)) {
|
|
match super::optional_header_str(headers, &super::X_FORWARDED_PROTO)? {
|
|
Some(value) if value.eq_ignore_ascii_case("http") => "http",
|
|
Some(value) if value.eq_ignore_ascii_case("https") => "https",
|
|
Some(_) => {
|
|
return Err(AppError::bad_request(
|
|
"x-forwarded-proto must be http or https",
|
|
));
|
|
}
|
|
None => "http",
|
|
}
|
|
} else {
|
|
"http"
|
|
};
|
|
if !parsed.scheme().eq_ignore_ascii_case(request_scheme) {
|
|
return Ok(None);
|
|
}
|
|
|
|
let host = super::required_header_str(headers, "host", "Host header is required")?;
|
|
Ok(
|
|
super::websocket::same_origin_as_host(&parsed, host, Some(request_scheme))?
|
|
.then(|| redirect_url.to_string()),
|
|
)
|
|
}
|
|
|
|
/// Get `OAuth2` authorization URL for login flow
|
|
///
|
|
/// GET /`api/oauth2/:provider/authorize?redirectUrl`=<url>
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
get,
|
|
path = "/api/oauth2/{provider}/authorize",
|
|
tag = "OAuth2",
|
|
params(
|
|
("provider" = String, Path, description = "OAuth2 provider instance name"),
|
|
AuthorizationUrlQuery
|
|
),
|
|
responses(
|
|
(status = 200, description = "OAuth2 authorization URL", body = GetAuthorizationUrlResponse),
|
|
(status = 400, description = "Invalid OAuth2 request", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn get_authorize_url(
|
|
request_meta: RequestMetadata,
|
|
State(state): State<AppState>,
|
|
Path(path): Path<OAuth2ProviderInstancePathRequest>,
|
|
Query(query): Query<AuthorizationUrlQuery>,
|
|
headers: HeaderMap,
|
|
) -> AppResult<Json<GetAuthorizationUrlResponse>> {
|
|
let oauth2_api = require_oauth2_api(&state)?;
|
|
let req = query.into_request(path.provider);
|
|
let request_allowed_redirect_url = request_allowed_web_callback(
|
|
req.redirect_url.as_deref(),
|
|
req.native,
|
|
&headers,
|
|
request_meta.0.socket_ip,
|
|
&state.runtime_settings.server,
|
|
)?;
|
|
let provider_for_log = req.provider.clone();
|
|
|
|
let request_meta = request_meta.0;
|
|
let response = state
|
|
.shared_api_runtime
|
|
.request_executor
|
|
.execute_public_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Read,
|
|
move |request_control| async move {
|
|
oauth2_api
|
|
.get_authorization_url_response_with_control(
|
|
req,
|
|
request_allowed_redirect_url,
|
|
Some(&request_control),
|
|
)
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Failed to get authorization URL: {}", e);
|
|
map_api_error(e)
|
|
})?;
|
|
|
|
debug!(
|
|
"Generated OAuth2 authorization URL for provider: {}",
|
|
provider_for_log
|
|
);
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
/// Exchange authorization code for JWT token (frontend-driven flow)
|
|
///
|
|
/// POST /api/oauth2/exchange
|
|
/// Body: { "code": "xxx", "state": "xxx" }
|
|
///
|
|
/// For bind flows (where the `OAuth2` state contains a `target_user_id`), the caller
|
|
/// must be authenticated and the authenticated user must match the `target_user_id`
|
|
/// stored in the state. For login flows, no authentication is required.
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
post,
|
|
path = "/api/oauth2/exchange",
|
|
tag = "OAuth2",
|
|
request_body = ExchangeAuthorizationCodeRequest,
|
|
responses(
|
|
(status = 200, description = "Authorization code exchanged", body = ExchangeAuthorizationCodeResponse),
|
|
(status = 400, description = "Invalid OAuth2 exchange request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 401, description = "Authentication required for bind flow", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn exchange_authorization_code(
|
|
request_meta: RequestMetadata,
|
|
State(state): State<AppState>,
|
|
connect_info: axum::extract::ConnectInfo<std::net::SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(req): Json<ExchangeAuthorizationCodeRequest>,
|
|
) -> AppResult<Json<ExchangeAuthorizationCodeResponse>> {
|
|
let oauth2_api = require_oauth2_api(&state)?;
|
|
let client_ip = synctv_adapter::client_ip::extract_client_ip_from_headers(
|
|
|ip| state.runtime_settings.server.is_trusted_proxy(ip),
|
|
connect_info.0.ip(),
|
|
&headers,
|
|
)
|
|
.map_err(|error| AppError::bad_request(error.to_string()))?;
|
|
let request_meta = request_meta.0;
|
|
|
|
let response = state
|
|
.shared_api_runtime
|
|
.request_executor
|
|
.execute_optional_user_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Auth,
|
|
move |request_control, authenticated| async move {
|
|
let current_user_id = authenticated
|
|
.as_ref()
|
|
.map(synctv_core::service::AuthenticatedToken::user_id);
|
|
oauth2_api
|
|
.exchange_authorization_code_response_with_control(
|
|
req,
|
|
current_user_id.as_ref(),
|
|
Some(client_ip),
|
|
Some(&request_control),
|
|
)
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Failed to exchange authorization code: {}", e);
|
|
map_oauth2_exchange_error(e)
|
|
})?;
|
|
|
|
info!(
|
|
"OAuth2 exchange successful (operation: {})",
|
|
response.operation
|
|
);
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
/// Get authorization URL for binding `OAuth2` provider to authenticated user
|
|
///
|
|
/// GET /`api/oauth2/:provider/bind?redirectUrl`=<url>
|
|
///
|
|
/// Requires authentication. The frontend then redirects to the `OAuth2` provider,
|
|
/// receives code/state, and calls exchange endpoint which will bind the provider.
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
get,
|
|
path = "/api/oauth2/{provider}/bind",
|
|
tag = "OAuth2",
|
|
params(
|
|
("provider" = String, Path, description = "OAuth2 provider instance name"),
|
|
BindAuthorizationUrlQuery
|
|
),
|
|
responses(
|
|
(status = 200, description = "OAuth2 bind authorization URL", body = GetAuthorizationUrlForBindResponse),
|
|
(status = 400, description = "Invalid OAuth2 bind request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema)
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
)
|
|
)
|
|
)]
|
|
pub async fn get_bind_authorize_url(
|
|
request_meta: RequestMetadata,
|
|
State(state): State<AppState>,
|
|
Path(path): Path<OAuth2ProviderInstancePathRequest>,
|
|
Query(query): Query<BindAuthorizationUrlQuery>,
|
|
headers: HeaderMap,
|
|
) -> AppResult<Json<GetAuthorizationUrlForBindResponse>> {
|
|
let oauth2_api = require_oauth2_api(&state)?;
|
|
let req = query.into_request(path.provider);
|
|
let request_allowed_redirect_url = request_allowed_web_callback(
|
|
req.redirect_url.as_deref(),
|
|
req.native,
|
|
&headers,
|
|
request_meta.0.socket_ip,
|
|
&state.runtime_settings.server,
|
|
)?;
|
|
let provider_for_log = req.provider.clone();
|
|
|
|
let request_meta = request_meta.0;
|
|
let response = state
|
|
.shared_api_runtime
|
|
.request_executor
|
|
.execute_user_with_control(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Write,
|
|
move |request_control, authenticated| async move {
|
|
oauth2_api
|
|
.get_authorization_url_for_bind_response_with_control(
|
|
&authenticated.user_id(),
|
|
req,
|
|
request_allowed_redirect_url,
|
|
Some(&request_control),
|
|
)
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Failed to get authorization URL for bind: {}", e);
|
|
map_api_error(e)
|
|
})?;
|
|
|
|
debug!(
|
|
"Generated OAuth2 bind URL for provider: {}",
|
|
provider_for_log
|
|
);
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
/// Unlink `OAuth2` provider from authenticated user
|
|
///
|
|
/// DELETE /`api/oauth2/type/:provider/unlink?providerInstanceName`=<optional>&`providerUserId`=<optional>
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
delete,
|
|
path = "/api/oauth2/type/{provider}/unlink",
|
|
tag = "OAuth2",
|
|
params(
|
|
("provider" = String, Path, description = "OAuth2 provider type"),
|
|
UnlinkProviderQuery
|
|
),
|
|
responses(
|
|
(status = 200, description = "OAuth2 provider unlinked", body = UnlinkProviderResponse),
|
|
(status = 400, description = "Invalid unlink request", body = crate::openapi::GoogleRpcStatusSchema),
|
|
(status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema)
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
)
|
|
)
|
|
)]
|
|
pub async fn unlink_provider(
|
|
request_meta: RequestMetadata,
|
|
State(state): State<AppState>,
|
|
Path(path): Path<OAuth2ProviderTypePathRequest>,
|
|
Query(query): Query<UnlinkProviderQuery>,
|
|
) -> AppResult<Json<UnlinkProviderResponse>> {
|
|
let req = query.into_request(oauth2_provider_type_path_to_proto(&path.provider)?);
|
|
let oauth2_api = require_oauth2_api(&state)?;
|
|
let provider_for_log = req.provider;
|
|
|
|
let request_meta = request_meta.0;
|
|
let response = state
|
|
.shared_api_runtime
|
|
.request_executor
|
|
.execute_user(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Write,
|
|
move |authenticated| async move {
|
|
oauth2_api
|
|
.unlink_provider_response(&authenticated.user_id(), req)
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Failed to unlink OAuth2 provider: {}", e);
|
|
map_api_error(e)
|
|
})?;
|
|
|
|
info!("OAuth2 provider unlinked: {}", provider_for_log);
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
/// List all available `OAuth2` provider instances
|
|
///
|
|
/// GET /api/oauth2/providers
|
|
///
|
|
/// Returns the configured `OAuth2` provider instances that clients can use
|
|
/// for login or account binding. No authentication required.
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
get,
|
|
path = "/api/oauth2/providers",
|
|
tag = "OAuth2",
|
|
responses(
|
|
(status = 200, description = "Available OAuth2 providers", body = ListAvailableProvidersResponse),
|
|
(status = 503, description = "OAuth2 is not configured", body = crate::openapi::GoogleRpcStatusSchema)
|
|
)
|
|
)
|
|
)]
|
|
pub async fn list_available_providers(
|
|
request_meta: RequestMetadata,
|
|
State(state): State<AppState>,
|
|
) -> AppResult<Json<ListAvailableProvidersResponse>> {
|
|
let oauth2_api = require_oauth2_api(&state)?;
|
|
let request_meta = request_meta.0;
|
|
|
|
let response = state
|
|
.shared_api_runtime
|
|
.request_executor
|
|
.execute_public(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Read,
|
|
|| async move { oauth2_api.list_available_providers_response().await },
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Failed to list available providers: {}", e);
|
|
map_api_error(e)
|
|
})?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
/// Get linked `OAuth2` providers for authenticated user
|
|
///
|
|
/// GET /api/oauth2/linked
|
|
///
|
|
/// Requires authentication.
|
|
#[cfg_attr(
|
|
feature = "openapi",
|
|
utoipa::path(
|
|
get,
|
|
path = "/api/oauth2/linked",
|
|
tag = "OAuth2",
|
|
responses(
|
|
(status = 200, description = "Linked OAuth2 providers", body = GetLinkedProvidersResponse),
|
|
(status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema)
|
|
),
|
|
security(
|
|
("bearer_auth" = [])
|
|
)
|
|
)
|
|
)]
|
|
pub async fn get_linked_providers(
|
|
request_meta: RequestMetadata,
|
|
State(state): State<AppState>,
|
|
) -> AppResult<Json<GetLinkedProvidersResponse>> {
|
|
let oauth2_api = require_oauth2_api(&state)?;
|
|
let request_meta = request_meta.0;
|
|
|
|
let response = state
|
|
.shared_api_runtime
|
|
.request_executor
|
|
.execute_user(
|
|
&request_meta,
|
|
EndpointRateLimitCategory::Read,
|
|
move |authenticated| async move {
|
|
oauth2_api
|
|
.get_linked_providers_response(&authenticated.user_id())
|
|
.await
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Failed to get linked providers: {}", e);
|
|
map_api_error(e)
|
|
})?;
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::http::{header, HeaderMap, StatusCode};
|
|
|
|
type TestResult<T = ()> = anyhow::Result<T>;
|
|
|
|
#[test]
|
|
fn test_oauth2_route_queries_ignore_path_fields() {
|
|
let authorize = serde_urlencoded::from_str::<AuthorizationUrlQuery>(
|
|
"provider=github-main&redirectUrl=http%3A%2F%2Flocalhost%2Fcallback",
|
|
)
|
|
.expect("unknown path field should be ignored");
|
|
let authorize = authorize.into_request("path-provider".to_string());
|
|
assert_eq!(authorize.provider, "path-provider");
|
|
assert_eq!(
|
|
authorize.redirect_url.as_deref(),
|
|
Some("http://localhost/callback")
|
|
);
|
|
|
|
let unlink = serde_urlencoded::from_str::<UnlinkProviderQuery>(
|
|
"provider=1&providerUserId=remote-user-1",
|
|
)
|
|
.expect("unknown path field should be ignored");
|
|
let github_provider =
|
|
synctv_proto::client::OAuth2ProviderType::Oauth2ProviderTypeGithub as i32;
|
|
let unlink = unlink.into_request(github_provider);
|
|
assert_eq!(unlink.provider, github_provider);
|
|
assert_eq!(unlink.provider_user_id, "remote-user-1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_oauth2_provider_instance_path_request_deserializes_proto_field_name() -> TestResult {
|
|
let req: OAuth2ProviderInstancePathRequest =
|
|
serde_json::from_str(r#"{"provider":"github-main"}"#)?;
|
|
assert_eq!(req.provider, "github-main");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_oauth2_provider_type_path_request_deserializes_proto_field_name() -> TestResult {
|
|
let req: OAuth2ProviderTypePathRequest = serde_json::from_str(r#"{"provider":"github"}"#)?;
|
|
assert_eq!(req.provider, "github");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_unlink_missing_binding_maps_to_http_not_found() {
|
|
let err = map_api_error(synctv_api_common::impls::ApiError::NotFound(
|
|
"No binding found for this provider".to_string(),
|
|
));
|
|
|
|
assert_eq!(err.status(), StatusCode::NOT_FOUND);
|
|
assert_eq!(err.message(), "No binding found for this provider");
|
|
}
|
|
|
|
#[test]
|
|
fn test_oauth2_missing_is_service_unavailable() {
|
|
let err = oauth2_unavailable_error();
|
|
assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
assert_eq!(err.message(), "OAuth2 is not available on this server.");
|
|
}
|
|
|
|
fn callback_headers(host: &str) -> TestResult<HeaderMap> {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(header::HOST, host.parse()?);
|
|
Ok(headers)
|
|
}
|
|
|
|
#[test]
|
|
fn same_origin_web_callback_is_request_allowed() -> TestResult {
|
|
let mut server = synctv_api_common::ApiServerSettings::default();
|
|
server.trusted_proxies = vec!["127.0.0.1".to_string()];
|
|
let mut headers = callback_headers("app.example.test")?;
|
|
headers.insert("x-forwarded-proto", "https".parse()?);
|
|
|
|
let allowed = request_allowed_web_callback(
|
|
Some("https://app.example.test/oauth2/callback"),
|
|
Some(false),
|
|
&headers,
|
|
Some("127.0.0.1".parse()?),
|
|
&server,
|
|
)?;
|
|
|
|
assert_eq!(
|
|
allowed.as_deref(),
|
|
Some("https://app.example.test/oauth2/callback")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn web_callback_requires_exact_origin_and_path() -> TestResult {
|
|
let mut server = synctv_api_common::ApiServerSettings::default();
|
|
server.trusted_proxies = vec!["127.0.0.1".to_string()];
|
|
let mut headers = callback_headers("app.example.test:8443")?;
|
|
headers.insert("x-forwarded-proto", "https".parse()?);
|
|
let peer = Some("127.0.0.1".parse()?);
|
|
|
|
for redirect in [
|
|
"https://evil.example.test:8443/oauth2/callback",
|
|
"https://app.example.test/oauth2/callback",
|
|
"https://app.example.test:8443/auth.html",
|
|
"https://app.example.test:8443/oauth2/callback?next=/rooms",
|
|
"https://app.example.test:8443/oauth2/callback#fragment",
|
|
] {
|
|
assert_eq!(
|
|
request_allowed_web_callback(Some(redirect), Some(false), &headers, peer, &server,)?,
|
|
None,
|
|
"unexpectedly allowed {redirect}",
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn native_and_untrusted_forwarded_callbacks_are_not_request_allowed() -> TestResult {
|
|
let server = synctv_api_common::ApiServerSettings::default();
|
|
let mut headers = callback_headers("app.example.test")?;
|
|
headers.insert("x-forwarded-proto", "https".parse()?);
|
|
let redirect = Some("https://app.example.test/oauth2/callback");
|
|
|
|
assert_eq!(
|
|
request_allowed_web_callback(
|
|
redirect,
|
|
Some(true),
|
|
&headers,
|
|
Some("127.0.0.1".parse()?),
|
|
&server,
|
|
)?,
|
|
None,
|
|
);
|
|
assert_eq!(
|
|
request_allowed_web_callback(
|
|
redirect,
|
|
Some(false),
|
|
&headers,
|
|
Some("198.51.100.10".parse()?),
|
|
&server,
|
|
)?,
|
|
None,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn trusted_proxy_callback_rejects_invalid_forwarded_proto() -> TestResult {
|
|
let mut server = synctv_api_common::ApiServerSettings::default();
|
|
server.trusted_proxies = vec!["127.0.0.1".to_string()];
|
|
let mut headers = callback_headers("app.example.test")?;
|
|
headers.insert("x-forwarded-proto", "javascript".parse()?);
|
|
|
|
let error = request_allowed_web_callback(
|
|
Some("https://app.example.test/oauth2/callback"),
|
|
Some(false),
|
|
&headers,
|
|
Some("127.0.0.1".parse()?),
|
|
&server,
|
|
)
|
|
.expect_err("invalid proxy scheme must fail");
|
|
|
|
assert_eq!(error.status(), StatusCode::BAD_REQUEST);
|
|
Ok(())
|
|
}
|
|
}
|