fix(auth): use authextractor for serviceauth too now

This commit is contained in:
Lewis
2026-03-14 13:04:14 +02:00
parent c680f3c419
commit bfaab7f4c3
13 changed files with 83 additions and 187 deletions
Generated
+16 -16
View File
@@ -6094,7 +6094,7 @@ dependencies = [
[[package]]
name = "tranquil-auth"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"anyhow",
"base32",
@@ -6117,7 +6117,7 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -6146,7 +6146,7 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"confique",
"serde",
@@ -6154,7 +6154,7 @@ dependencies = [
[[package]]
name = "tranquil-crypto"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -6170,7 +6170,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"async-trait",
"chrono",
@@ -6187,7 +6187,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -6203,7 +6203,7 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"async-trait",
"bytes",
@@ -6214,7 +6214,7 @@ dependencies = [
[[package]]
name = "tranquil-lexicon"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"chrono",
"hickory-resolver",
@@ -6232,7 +6232,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"anyhow",
"axum",
@@ -6255,7 +6255,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"aes-gcm",
"anyhow",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "tranquil-repo"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"bytes",
"cid",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"async-trait",
"backon",
@@ -6380,7 +6380,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"axum",
"futures",
@@ -6396,7 +6396,7 @@ dependencies = [
[[package]]
name = "tranquil-storage"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"async-trait",
"aws-config",
@@ -6413,7 +6413,7 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"chrono",
"cid",
+1 -1
View File
@@ -20,7 +20,7 @@ members = [
]
[workspace.package]
version = "0.4.0"
version = "0.4.1"
edition = "2024"
license = "AGPL-3.0-or-later"
+4 -6
View File
@@ -327,12 +327,10 @@ impl TranquilConfig {
errors: &mut Vec<String>,
) {
self.validate_sso_provider(prefix, p, errors);
if p.get_enabled() {
if p.get_issuer().is_none() {
errors.push(format!(
"{prefix}.issuer is required when {prefix}.enabled = true"
));
}
if p.get_enabled() && p.get_issuer().is_none() {
errors.push(format!(
"{prefix}.issuer is required when {prefix}.enabled = true"
));
}
}
+1 -3
View File
@@ -46,9 +46,7 @@ pub fn is_valid_uri(s: &str) -> bool {
}
pub fn is_valid_cid(s: &str) -> bool {
s.len() >= 8
&& s.chars().all(|c| c.is_ascii_alphanumeric())
&& s.starts_with(|c: char| c == 'b' || c == 'z' || c == 'Q')
s.len() >= 8 && s.chars().all(|c| c.is_ascii_alphanumeric()) && s.starts_with(['b', 'z', 'Q'])
}
pub fn is_valid_language(s: &str) -> bool {
+8 -9
View File
@@ -339,15 +339,14 @@ fn validate_blob_ref(
}
}
if let Some(max_size) = lex_blob.max_size {
if let Some(size) = obj.get("size").and_then(|v| v.as_u64()) {
if size > max_size {
return Err(LexValidationError::field(
path,
format!("blob size {} exceeds max_size {}", size, max_size),
));
}
}
if let (Some(max_size), Some(size)) =
(lex_blob.max_size, obj.get("size").and_then(|v| v.as_u64()))
&& size > max_size
{
return Err(LexValidationError::field(
path,
format!("blob size {} exceeds max_size {}", size, max_size),
));
}
Ok(())
@@ -1,8 +1,7 @@
use crate::AccountStatus;
use crate::api::error::ApiError;
use crate::auth::extractor::{Auth, Permissive};
use crate::state::AppState;
use crate::types::Did;
use axum::http::Method;
use axum::{
Json,
extract::{Query, State},
@@ -10,7 +9,6 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashSet;
use std::sync::LazyLock;
use tracing::{error, info, warn};
@@ -59,109 +57,22 @@ pub struct GetServiceAuthOutput {
pub async fn get_service_auth(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
auth: Auth<Permissive>,
Query(params): Query<GetServiceAuthParams>,
) -> Response {
let auth_header = crate::util::get_header_str(&headers, axum::http::header::AUTHORIZATION);
let dpop_proof = crate::util::get_header_str(&headers, crate::util::HEADER_DPOP);
info!(
has_auth_header = auth_header.is_some(),
has_dpop_proof = dpop_proof.is_some(),
did = %&auth.did,
is_oauth = auth.is_oauth(),
aud = %params.aud,
lxm = ?params.lxm,
"getServiceAuth called"
);
let auth_header = match auth_header {
Some(h) => h.trim(),
None => {
warn!("getServiceAuth: no Authorization header");
return ApiError::AuthenticationRequired.into_response();
}
};
let extracted = match crate::auth::extract_auth_token_from_header(Some(auth_header)) {
Some(e) => e,
None => {
warn!(auth_scheme = ?auth_header.split_whitespace().next(), "getServiceAuth: invalid auth scheme");
return ApiError::AuthenticationRequired.into_response();
}
};
let token = extracted.token;
let auth_user = if extracted.scheme.is_dpop() {
match crate::oauth::verify::verify_oauth_access_token(
state.oauth_repo.as_ref(),
&token,
dpop_proof,
Method::GET.as_str(),
&crate::util::build_full_url(&format!(
"/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}",
params.aud,
params.lxm.as_ref().map_or("", |n| n.as_str())
)),
)
.await
{
Ok(result) => {
let did: Did = match result.did.parse() {
Ok(d) => d,
Err(_) => {
return ApiError::InternalError(Some("Invalid DID in token".into()))
.into_response();
}
};
crate::auth::AuthenticatedUser {
did,
is_admin: false,
status: AccountStatus::Active,
scope: result.scope,
key_bytes: None,
controller_did: None,
auth_source: crate::auth::AuthSource::OAuth,
}
}
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => {
return (
StatusCode::UNAUTHORIZED,
[("DPoP-Nonce", nonce)],
Json(json!({
"error": "use_dpop_nonce",
"message": "DPoP nonce required"
})),
)
.into_response();
}
Err(crate::oauth::OAuthError::ExpiredToken(msg)) => {
warn!(error = %msg, "getServiceAuth DPoP token expired");
return ApiError::OAuthExpiredToken(Some(msg)).into_response();
}
Err(e) => {
warn!(error = ?e, "getServiceAuth DPoP auth validation failed");
return ApiError::AuthenticationFailed(Some(format!("{:?}", e))).into_response();
}
}
} else {
match crate::auth::validate_bearer_token_for_service_auth(state.user_repo.as_ref(), &token)
.await
{
Ok(user) => user,
Err(e) => {
warn!(error = ?e, "getServiceAuth auth validation failed");
return ApiError::from(e).into_response();
}
}
};
info!(
did = %&auth_user.did,
is_oauth = auth_user.is_oauth(),
has_key = auth_user.key_bytes.is_some(),
"getServiceAuth auth validated"
);
let key_bytes = match &auth_user.key_bytes {
let key_bytes = match &auth.key_bytes {
Some(kb) => kb.clone(),
None => {
warn!(did = %&auth_user.did, "getServiceAuth: OAuth token has no key_bytes, fetching from DB");
match state.user_repo.get_user_info_by_did(&auth_user.did).await {
warn!(did = %&auth.did, "getServiceAuth: no key_bytes in auth, fetching from DB");
match state.user_repo.get_user_info_by_did(&auth.did).await {
Ok(Some(info)) => match info.key_bytes {
Some(key_bytes_enc) => {
match crate::config::decrypt_key(&key_bytes_enc, info.encryption_version) {
@@ -202,15 +113,15 @@ pub async fn get_service_auth(
if let Some(method) = lxm {
if let Err(e) = crate::auth::scope_check::check_rpc_scope(
&auth_user.auth_source,
auth_user.scope.as_deref(),
&auth.auth_source,
auth.scope.as_deref(),
params.aud.as_str(),
method.as_str(),
) {
return e;
}
} else if auth_user.is_oauth() {
let permissions = auth_user.permissions();
} else if auth.is_oauth() {
let permissions = auth.permissions();
if !permissions.has_full_access() {
return ApiError::InvalidRequest(
"OAuth tokens with granular scopes must specify an lxm parameter".into(),
@@ -219,15 +130,7 @@ pub async fn get_service_auth(
}
}
let is_takendown = state
.user_repo
.get_status_by_did(&auth_user.did)
.await
.ok()
.flatten()
.is_some_and(|s| s.takedown_ref.is_some());
if is_takendown && lxm != Some(&*CREATE_ACCOUNT_NSID) {
if auth.status.is_takendown() && lxm != Some(&*CREATE_ACCOUNT_NSID) {
return ApiError::InvalidToken(Some("Bad token scope".into())).into_response();
}
@@ -265,7 +168,7 @@ pub async fn get_service_auth(
}
let service_token = match crate::auth::create_service_token(
&auth_user.did,
&auth.did,
params.aud.as_str(),
lxm_for_token,
&key_bytes,
@@ -68,7 +68,7 @@ pub async fn create_session(
let pds_host = &tranquil_config::get().server.hostname;
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_identifier =
NormalizedLoginIdentifier::normalize(&input.identifier, &hostname_for_handles);
NormalizedLoginIdentifier::normalize(&input.identifier, hostname_for_handles);
info!(
"Normalized identifier: {} -> {}",
input.identifier, normalized_identifier
+3 -2
View File
@@ -355,7 +355,7 @@ async fn validate_bearer_token_with_options_internal(
)
.await;
let status_cache_key = crate::cache_keys::user_status_key(&did.to_string());
let status_cache_key = crate::cache_keys::user_status_key(did.as_ref());
let cached = CachedUserStatus {
deactivated: user.deactivated_at.is_some(),
takendown: user.takedown_ref.is_some(),
@@ -394,7 +394,7 @@ async fn validate_bearer_token_with_options_internal(
match verify_access_token_typed(token, &decrypted_key) {
Ok(token_data) => {
let jti = &token_data.claims.jti;
let session_cache_key = crate::cache_keys::session_key(&did, &jti);
let session_cache_key = crate::cache_keys::session_key(&did, jti);
let mut session_valid = false;
if let Some(c) = cache {
@@ -530,6 +530,7 @@ pub enum AccountRequirement {
AnyStatus,
}
#[allow(clippy::too_many_arguments)]
pub async fn validate_token_with_dpop(
user_repo: &dyn UserRepository,
oauth_repo: &dyn OAuthRepository,
+1 -3
View File
@@ -653,8 +653,6 @@ pub fn app(state: AppState) -> Router {
get(oauth::endpoints::oauth_authorization_server),
);
if cfg!(feature = "frontend") {}
let router = Router::new()
.nest_service("/xrpc", xrpc_service)
.nest("/oauth", oauth_router)
@@ -716,7 +714,7 @@ pub fn app(state: AppState) -> Router {
let spa_router = Router::new().fallback_service(ServeFile::new(&index_path));
let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path));
let serve_dir = ServeDir::new(frontend_dir).not_found_service(ServeFile::new(&index_path));
return router
.route(
@@ -256,7 +256,7 @@ pub async fn authorize_get(
if let Some(ref login_hint) = request_data.parameters.login_hint {
tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation");
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized = NormalizedLoginIdentifier::normalize(login_hint, &hostname_for_handles);
let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles);
tracing::info!(normalized = %normalized, "Normalized login_hint");
match state
@@ -530,7 +530,7 @@ pub async fn authorize_post(
};
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_username =
NormalizedLoginIdentifier::normalize(&form.username, &hostname_for_handles);
NormalizedLoginIdentifier::normalize(&form.username, hostname_for_handles);
tracing::debug!(
original_username = %form.username,
normalized_username = %normalized_username,
@@ -2102,7 +2102,7 @@ pub async fn check_user_has_passkeys(
) -> Response {
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let bare_identifier =
BareLoginIdentifier::from_identifier(&query.identifier, &hostname_for_handles);
BareLoginIdentifier::from_identifier(&query.identifier, hostname_for_handles);
let user = state
.user_repo
@@ -2134,7 +2134,7 @@ pub async fn check_user_security_status(
) -> Response {
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_identifier =
NormalizedLoginIdentifier::normalize(&query.identifier, &hostname_for_handles);
NormalizedLoginIdentifier::normalize(&query.identifier, hostname_for_handles);
let user = state
.user_repo
@@ -2242,7 +2242,7 @@ pub async fn passkey_start(
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_username =
NormalizedLoginIdentifier::normalize(&form.identifier, &hostname_for_handles);
NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles);
let user = match state
.user_repo
+4 -4
View File
@@ -774,10 +774,10 @@ pub async fn check_handle_available(
};
let available_domains = tranquil_config::get().server.available_user_domain_list();
if let Some(ref d) = query.domain {
if !available_domains.iter().any(|ad| ad == d) {
return Err(ApiError::InvalidRequest("Unknown user domain".into()));
}
if let Some(ref d) = query.domain
&& !available_domains.iter().any(|ad| ad == d)
{
return Err(ApiError::InvalidRequest("Unknown user domain".into()));
}
let domain = query.domain.as_deref().unwrap_or(&available_domains[0]);
let full_handle = format!("{}.{}", validated, domain);
+1 -1
View File
@@ -224,7 +224,7 @@ impl AppState {
.acquire_timeout(std::time::Duration::from_secs(acquire_timeout_secs))
.idle_timeout(std::time::Duration::from_secs(300))
.max_lifetime(std::time::Duration::from_secs(1800))
.connect(&database_url)
.connect(database_url)
.await
.map_err(|e| format!("Failed to connect to Postgres: {}", e))?;
+25 -26
View File
@@ -152,12 +152,12 @@ fn check_banned_content(
check_string_field(obj, "description")?;
}
"app.bsky.feed.generator" => {
if let Some(rkey) = rkey {
if crate::moderation::has_explicit_slur(rkey) {
return Err(ValidationError::BannedContent {
path: "rkey".to_string(),
});
}
if let Some(rkey) = rkey
&& crate::moderation::has_explicit_slur(rkey)
{
return Err(ValidationError::BannedContent {
path: "rkey".to_string(),
});
}
check_string_field(obj, "displayName")?;
}
@@ -169,12 +169,12 @@ fn check_banned_content(
fn check_post_banned_content(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) {
tags.iter().enumerate().try_for_each(|(i, tag)| {
if let Some(tag_str) = tag.as_str() {
if crate::moderation::has_explicit_slur(tag_str) {
return Err(ValidationError::BannedContent {
path: format!("tags/{}", i),
});
}
if let Some(tag_str) = tag.as_str()
&& crate::moderation::has_explicit_slur(tag_str)
{
return Err(ValidationError::BannedContent {
path: format!("tags/{}", i),
});
}
Ok(())
})?;
@@ -187,14 +187,13 @@ fn check_post_banned_content(obj: &serde_json::Map<String, Value>) -> Result<(),
.get("$type")
.and_then(|v| v.as_str())
.is_some_and(|t| t == "app.bsky.richtext.facet#tag");
if is_tag {
if let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) {
if crate::moderation::has_explicit_slur(tag) {
return Err(ValidationError::BannedContent {
path: format!("facets/{}/features/{}/tag", i, j),
});
}
}
if is_tag
&& let Some(tag) = feature.get("tag").and_then(|v| v.as_str())
&& crate::moderation::has_explicit_slur(tag)
{
return Err(ValidationError::BannedContent {
path: format!("facets/{}/features/{}/tag", i, j),
});
}
Ok(())
})?;
@@ -209,12 +208,12 @@ fn check_string_field(
obj: &serde_json::Map<String, Value>,
field: &str,
) -> Result<(), ValidationError> {
if let Some(value) = obj.get(field).and_then(|v| v.as_str()) {
if crate::moderation::has_explicit_slur(value) {
return Err(ValidationError::BannedContent {
path: field.to_string(),
});
}
if let Some(value) = obj.get(field).and_then(|v| v.as_str())
&& crate::moderation::has_explicit_slur(value)
{
return Err(ValidationError::BannedContent {
path: field.to_string(),
});
}
Ok(())
}