fix: bulk type safety improvements, added a couple of tests

This commit is contained in:
lewis
2026-02-10 15:25:35 +00:00
committed by Tangled
parent 1ff22c3dee
commit 91cfc536c6
146 changed files with 5084 additions and 2758 deletions
+8
View File
@@ -33,6 +33,10 @@ test-group = "heavy-load-tests"
filter = "test(/two_node_stress_concurrent_load/)"
test-group = "heavy-load-tests"
[[profile.default.overrides]]
filter = "binary(repo_lifecycle)"
test-group = "heavy-load-tests"
[[profile.ci.overrides]]
filter = "test(/import_with_verification/) | test(/plc_migration/)"
test-group = "serial-env-tests"
@@ -48,3 +52,7 @@ test-group = "heavy-load-tests"
[[profile.ci.overrides]]
filter = "test(/two_node_stress_concurrent_load/)"
test-group = "heavy-load-tests"
[[profile.ci.overrides]]
filter = "binary(repo_lifecycle)"
test-group = "heavy-load-tests"
Generated
+15 -14
View File
@@ -5893,7 +5893,7 @@ dependencies = [
[[package]]
name = "tranquil-auth"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"anyhow",
"base32",
@@ -5915,7 +5915,7 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5928,7 +5928,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "tranquil-crypto"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -5958,7 +5958,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"async-trait",
"chrono",
@@ -5975,7 +5975,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5991,7 +5991,7 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"async-trait",
"bytes",
@@ -6001,7 +6001,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"anyhow",
"axum",
@@ -6024,7 +6024,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"aes-gcm",
"anyhow",
@@ -6109,7 +6109,7 @@ dependencies = [
[[package]]
name = "tranquil-repo"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"bytes",
"cid",
@@ -6121,7 +6121,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"async-trait",
"backon",
@@ -6145,7 +6145,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"axum",
"futures",
@@ -6153,6 +6153,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tracing",
"urlencoding",
@@ -6160,7 +6161,7 @@ dependencies = [
[[package]]
name = "tranquil-storage"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"async-trait",
"aws-config",
@@ -6176,7 +6177,7 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"chrono",
"cid",
+1 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.2.0"
version = "0.2.1"
edition = "2024"
license = "AGPL-3.0-or-later"
+2 -34
View File
@@ -5,13 +5,6 @@
### Storage backend abstraction
Make storage layers swappable via traits.
filesystem blob storage
- [ ] FilesystemBlobStorage implementation
- [ ] directory structure (content-addressed like blobs/{cid} already used in objsto)
- [ ] atomic writes (write to temp, rename)
- [ ] config option to choose backend (env var or config flag)
- [ ] also traitify BackupStorage (currently hardcoded to objsto)
sqlite database backend
- [ ] abstract db layer behind trait (queries, transactions, migrations)
- [ ] sqlite implementation matching postgres behavior
@@ -21,6 +14,8 @@ sqlite database backend
- [ ] config option to choose backend (postgres vs sqlite)
- [ ] document tradeoffs (sqlite for single-user/small, postgres for multi-user/scale)
- [ ] skip sqlite and just straight-up do our own db?!
### Plugin system
WASM component model plugins. Compile to wasm32-wasip2, sandboxed via wasmtime, capability-gated. Based on zed's extensions.
@@ -131,30 +126,3 @@ plugin hooks (once core exists)
- [ ] on_access_grant_request for custom authorization
- [ ] on_key_rotation to notify interested parties
---
## Completed
Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, full admin API, moderation reports.
did:web support: Self-hosted did:web (subdomain format `did:web:handle.pds.com`), external/BYOD did:web, DID document serving via `/.well-known/did.json`, clear registration warnings about did:web trade-offs vs did:plc.
OAuth 2.1: Authorization server metadata, JWKS, PAR, authorize endpoint with login UI, token endpoint (auth code + refresh), revocation, introspection, DPoP, PKCE S256, client metadata validation, private_key_jwt verification.
OAuth Scope Enforcement: Full granular scope system with consent UI, human-readable scope descriptions, per-client scope preferences, scope parsing (repo/blob/rpc/account/identity), endpoint-level scope checks, DPoP token support in auth extractors, token revocation on re-authorization, response_mode support (query/fragment).
App endpoints: getPreferences, putPreferences, getProfile, getProfiles, getTimeline, getAuthorFeed, getActorLikes, getPostThread, getFeed, registerPush (all with local-first + proxy fallback).
Infrastructure: Sequencer with cursor replay, postgres repo storage with atomic transactions, valkey DID cache, debounced crawler notifications with circuit breakers, multi-channel notifications (email/Discord/Telegram/Signal), image processing, distributed rate limiting, security hardening.
Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel, OAuth consent screen with scope selection.
Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons.
Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP with QR setup, backup codes (hashed, one-time use), passkey-only account creation, trusted devices (remember this browser), re-auth for sensitive actions, rate-limited 2FA attempts, settings UI for managing all auth methods.
App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords.
Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices.
Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management, handle preservation (keep existing external handle via DNS/HTTP verification or create new PDS-subdomain handle).
+10 -10
View File
@@ -4,22 +4,22 @@ mod types;
mod verify;
pub use token::{
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, create_access_token, create_access_token_hs256,
create_access_token_hs256_with_metadata, create_access_token_with_delegation,
create_access_token_with_metadata, create_access_token_with_scope_metadata,
create_refresh_token, create_refresh_token_hs256, create_refresh_token_hs256_with_metadata,
create_refresh_token_with_metadata, create_service_token, create_service_token_hs256,
create_access_token, create_access_token_hs256, create_access_token_hs256_with_metadata,
create_access_token_with_delegation, create_access_token_with_metadata,
create_access_token_with_scope_metadata, create_refresh_token, create_refresh_token_hs256,
create_refresh_token_hs256_with_metadata, create_refresh_token_with_metadata,
create_service_token, create_service_token_hs256,
};
pub use totp::{
decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
verify_backup_code, verify_totp_code,
TotpError, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
is_backup_code_format, verify_backup_code, verify_totp_code,
};
pub use types::{
ActClaim, Claims, Header, TokenData, TokenVerifyError, TokenWithMetadata, UnsafeClaims,
ActClaim, Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
TokenVerifyError, TokenWithMetadata, UnsafeClaims,
};
pub use verify::{
+32 -38
View File
@@ -1,4 +1,6 @@
use super::types::{ActClaim, Claims, Header, TokenWithMetadata};
use super::types::{
ActClaim, Claims, Header, SigningAlgorithm, TokenScope, TokenType, TokenWithMetadata,
};
use anyhow::Result;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
@@ -9,14 +11,6 @@ use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
pub const TOKEN_TYPE_ACCESS: &str = "at+jwt";
pub const TOKEN_TYPE_REFRESH: &str = "refresh+jwt";
pub const TOKEN_TYPE_SERVICE: &str = "jwt";
pub const SCOPE_ACCESS: &str = "com.atproto.access";
pub const SCOPE_REFRESH: &str = "com.atproto.refresh";
pub const SCOPE_APP_PASS: &str = "com.atproto.appPass";
pub const SCOPE_APP_PASS_PRIVILEGED: &str = "com.atproto.appPassPrivileged";
pub fn create_access_token(did: &str, key_bytes: &[u8]) -> Result<String> {
Ok(create_access_token_with_metadata(did, key_bytes)?.token)
}
@@ -35,11 +29,11 @@ pub fn create_access_token_with_scope_metadata(
scopes: Option<&str>,
hostname: Option<&str>,
) -> Result<TokenWithMetadata> {
let scope = scopes.unwrap_or(SCOPE_ACCESS);
let scope = scopes.unwrap_or(TokenScope::Access.as_str());
create_signed_token_with_metadata(
did,
scope,
TOKEN_TYPE_ACCESS,
TokenType::Access,
key_bytes,
Duration::minutes(15),
hostname,
@@ -53,12 +47,12 @@ pub fn create_access_token_with_delegation(
controller_did: Option<&str>,
hostname: Option<&str>,
) -> Result<TokenWithMetadata> {
let scope = scopes.unwrap_or(SCOPE_ACCESS);
let scope = scopes.unwrap_or(TokenScope::Access.as_str());
let act = controller_did.map(|c| ActClaim { sub: c.to_string() });
create_signed_token_with_act(
did,
scope,
TOKEN_TYPE_ACCESS,
TokenType::Access,
key_bytes,
Duration::minutes(15),
act,
@@ -72,8 +66,8 @@ pub fn create_refresh_token_with_metadata(
) -> Result<TokenWithMetadata> {
create_signed_token_with_metadata(
did,
SCOPE_REFRESH,
TOKEN_TYPE_REFRESH,
TokenScope::Refresh.as_str(),
TokenType::Refresh,
key_bytes,
Duration::days(14),
None,
@@ -92,8 +86,8 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -
iss: did.to_owned(),
sub: did.to_owned(),
aud: aud.to_owned(),
exp: expiration as usize,
iat: Utc::now().timestamp() as usize,
exp: expiration,
iat: Utc::now().timestamp(),
scope: None,
lxm: Some(lxm.to_string()),
jti: uuid::Uuid::new_v4().to_string(),
@@ -106,7 +100,7 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -
fn create_signed_token_with_metadata(
did: &str,
scope: &str,
typ: &str,
typ: TokenType,
key_bytes: &[u8],
duration: Duration,
hostname: Option<&str>,
@@ -117,7 +111,7 @@ fn create_signed_token_with_metadata(
fn create_signed_token_with_act(
did: &str,
scope: &str,
typ: &str,
typ: TokenType,
key_bytes: &[u8],
duration: Duration,
act: Option<ActClaim>,
@@ -140,8 +134,8 @@ fn create_signed_token_with_act(
iss: did.to_owned(),
sub: did.to_owned(),
aud: format!("did:web:{}", aud_hostname),
exp: expiration as usize,
iat: Utc::now().timestamp() as usize,
exp: expiration,
iat: Utc::now().timestamp(),
scope: Some(scope.to_string()),
lxm: None,
jti: jti.clone(),
@@ -158,13 +152,13 @@ fn create_signed_token_with_act(
}
fn sign_claims(claims: Claims, key: &SigningKey) -> Result<String> {
sign_claims_with_type(claims, key, TOKEN_TYPE_SERVICE)
sign_claims_with_type(claims, key, TokenType::Service)
}
fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: &str) -> Result<String> {
fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: TokenType) -> Result<String> {
let header = Header {
alg: "ES256K".to_string(),
typ: typ.to_string(),
alg: SigningAlgorithm::ES256K,
typ,
};
let header_json = serde_json::to_string(&header)?;
@@ -194,8 +188,8 @@ pub fn create_access_token_hs256_with_metadata(
) -> Result<TokenWithMetadata> {
create_hs256_token_with_metadata(
did,
SCOPE_ACCESS,
TOKEN_TYPE_ACCESS,
TokenScope::Access.as_str(),
TokenType::Access,
secret,
Duration::minutes(15),
)
@@ -207,8 +201,8 @@ pub fn create_refresh_token_hs256_with_metadata(
) -> Result<TokenWithMetadata> {
create_hs256_token_with_metadata(
did,
SCOPE_REFRESH,
TOKEN_TYPE_REFRESH,
TokenScope::Refresh.as_str(),
TokenType::Refresh,
secret,
Duration::days(14),
)
@@ -229,21 +223,21 @@ pub fn create_service_token_hs256(
iss: did.to_owned(),
sub: did.to_owned(),
aud: aud.to_owned(),
exp: expiration as usize,
iat: Utc::now().timestamp() as usize,
exp: expiration,
iat: Utc::now().timestamp(),
scope: None,
lxm: Some(lxm.to_string()),
jti: uuid::Uuid::new_v4().to_string(),
act: None,
};
sign_claims_hs256(claims, TOKEN_TYPE_SERVICE, secret)
sign_claims_hs256(claims, TokenType::Service, secret)
}
fn create_hs256_token_with_metadata(
did: &str,
scope: &str,
typ: &str,
typ: TokenType,
secret: &[u8],
duration: Duration,
) -> Result<TokenWithMetadata> {
@@ -261,8 +255,8 @@ fn create_hs256_token_with_metadata(
"did:web:{}",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
),
exp: expiration as usize,
iat: Utc::now().timestamp() as usize,
exp: expiration,
iat: Utc::now().timestamp(),
scope: Some(scope.to_string()),
lxm: None,
jti: jti.clone(),
@@ -278,10 +272,10 @@ fn create_hs256_token_with_metadata(
})
}
fn sign_claims_hs256(claims: Claims, typ: &str, secret: &[u8]) -> Result<String> {
fn sign_claims_hs256(claims: Claims, typ: TokenType, secret: &[u8]) -> Result<String> {
let header = Header {
alg: "HS256".to_string(),
typ: typ.to_string(),
alg: SigningAlgorithm::HS256,
typ,
};
let header_json = serde_json::to_string(&header)?;
+29 -9
View File
@@ -1,12 +1,32 @@
use base32::Alphabet;
use rand::RngCore;
use rand::{Rng, RngCore};
use subtle::ConstantTimeEq;
use totp_rs::{Algorithm, TOTP};
const TOTP_DIGITS: usize = 6;
const TOTP_STEP: u64 = 30;
const TOTP_STEP_SIGNED: i64 = TOTP_STEP as i64;
const TOTP_SECRET_LENGTH: usize = 20;
#[derive(Debug)]
pub enum TotpError {
CreationFailed(String),
QrGenerationFailed(String),
HashFailed(String),
}
impl std::fmt::Display for TotpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CreationFailed(e) => write!(f, "TOTP creation failed: {}", e),
Self::QrGenerationFailed(e) => write!(f, "QR generation failed: {}", e),
Self::HashFailed(e) => write!(f, "Hash failed: {}", e),
}
}
}
impl std::error::Error for TotpError {}
pub fn generate_totp_secret() -> Vec<u8> {
let mut secret = vec![0u8; TOTP_SECRET_LENGTH];
rand::thread_rng().fill_bytes(&mut secret);
@@ -31,7 +51,7 @@ fn create_totp(
secret: Vec<u8>,
issuer: Option<String>,
account_name: String,
) -> Result<TOTP, String> {
) -> Result<TOTP, TotpError> {
TOTP::new(
Algorithm::SHA1,
TOTP_DIGITS,
@@ -41,7 +61,7 @@ fn create_totp(
issuer,
account_name,
)
.map_err(|e| format!("Failed to create TOTP: {}", e))
.map_err(|e| TotpError::CreationFailed(e.to_string()))
}
pub fn verify_totp_code(secret: &[u8], code: &str) -> bool {
@@ -60,7 +80,7 @@ pub fn verify_totp_code(secret: &[u8], code: &str) -> bool {
.unwrap_or(0);
[-1i64, 0, 1].iter().any(|&offset| {
let time = (now as i64 + offset * TOTP_STEP as i64) as u64;
let time = now.wrapping_add_signed(offset * TOTP_STEP_SIGNED);
let expected = totp.generate(time);
let is_valid: bool = code.as_bytes().ct_eq(expected.as_bytes()).into();
is_valid
@@ -84,7 +104,7 @@ pub fn generate_qr_png_base64(
secret: &[u8],
account_name: &str,
issuer: &str,
) -> Result<String, String> {
) -> Result<String, TotpError> {
use base64::{Engine, engine::general_purpose::STANDARD};
let totp = create_totp(
@@ -95,7 +115,7 @@ pub fn generate_qr_png_base64(
let qr_png = totp
.get_qr_png()
.map_err(|e| format!("Failed to generate QR code: {}", e))?;
.map_err(|e| TotpError::QrGenerationFailed(e.to_string()))?;
Ok(STANDARD.encode(qr_png))
}
@@ -112,7 +132,7 @@ pub fn generate_backup_codes() -> Vec<String> {
(0..BACKUP_CODE_COUNT).for_each(|_| {
let code: String = (0..BACKUP_CODE_LENGTH)
.map(|_| {
let idx = (rng.next_u32() as usize) % BACKUP_CODE_ALPHABET.len();
let idx = rng.gen_range(0..BACKUP_CODE_ALPHABET.len());
BACKUP_CODE_ALPHABET[idx] as char
})
.collect();
@@ -122,8 +142,8 @@ pub fn generate_backup_codes() -> Vec<String> {
codes
}
pub fn hash_backup_code(code: &str) -> Result<String, String> {
bcrypt::hash(code, BACKUP_CODE_BCRYPT_COST).map_err(|e| format!("Failed to hash code: {}", e))
pub fn hash_backup_code(code: &str) -> Result<String, TotpError> {
bcrypt::hash(code, BACKUP_CODE_BCRYPT_COST).map_err(|e| TotpError::HashFailed(e.to_string()))
}
pub fn verify_backup_code(code: &str, hash: &str) -> bool {
+202 -5
View File
@@ -1,6 +1,203 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Serialize, de, ser};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenType {
Access,
Refresh,
Service,
}
impl TokenType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Access => "at+jwt",
Self::Refresh => "refresh+jwt",
Self::Service => "jwt",
}
}
}
impl fmt::Display for TokenType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for TokenType {
type Err = TokenTypeParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"at+jwt" => Ok(Self::Access),
"refresh+jwt" => Ok(Self::Refresh),
"jwt" => Ok(Self::Service),
_ => Err(TokenTypeParseError(s.to_string())),
}
}
}
#[derive(Debug, Clone)]
pub struct TokenTypeParseError(pub String);
impl fmt::Display for TokenTypeParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "unknown token type: {}", self.0)
}
}
impl std::error::Error for TokenTypeParseError {}
impl Serialize for TokenType {
fn serialize<S: ser::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for TokenType {
fn deserialize<D: de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::from_str(&s).map_err(de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigningAlgorithm {
ES256K,
HS256,
}
impl SigningAlgorithm {
pub fn as_str(&self) -> &'static str {
match self {
Self::ES256K => "ES256K",
Self::HS256 => "HS256",
}
}
}
impl fmt::Display for SigningAlgorithm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for SigningAlgorithm {
type Err = SigningAlgorithmParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"ES256K" => Ok(Self::ES256K),
"HS256" => Ok(Self::HS256),
_ => Err(SigningAlgorithmParseError(s.to_string())),
}
}
}
#[derive(Debug, Clone)]
pub struct SigningAlgorithmParseError(pub String);
impl fmt::Display for SigningAlgorithmParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "unknown signing algorithm: {}", self.0)
}
}
impl std::error::Error for SigningAlgorithmParseError {}
impl Serialize for SigningAlgorithm {
fn serialize<S: ser::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for SigningAlgorithm {
fn deserialize<D: de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::from_str(&s).map_err(de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenScope {
Access,
Refresh,
AppPass,
AppPassPrivileged,
Custom(String),
}
impl TokenScope {
pub fn as_str(&self) -> &str {
match self {
Self::Access => "com.atproto.access",
Self::Refresh => "com.atproto.refresh",
Self::AppPass => "com.atproto.appPass",
Self::AppPassPrivileged => "com.atproto.appPassPrivileged",
Self::Custom(s) => s,
}
}
pub fn is_access_like(&self) -> bool {
matches!(self, Self::Access | Self::AppPass | Self::AppPassPrivileged)
}
}
impl fmt::Display for TokenScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for TokenScope {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"com.atproto.access" => Self::Access,
"com.atproto.refresh" => Self::Refresh,
"com.atproto.appPass" => Self::AppPass,
"com.atproto.appPassPrivileged" => Self::AppPassPrivileged,
other => Self::Custom(other.to_string()),
})
}
}
impl Serialize for TokenScope {
fn serialize<S: ser::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for TokenScope {
fn deserialize<D: de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Self::from_str(&s).unwrap_or_else(|e| match e {}))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenDecodeError {
InvalidFormat,
Base64DecodeFailed,
JsonDecodeFailed,
MissingClaim,
}
impl fmt::Display for TokenDecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidFormat => write!(f, "Invalid token format"),
Self::Base64DecodeFailed => write!(f, "Base64 decode failed"),
Self::JsonDecodeFailed => write!(f, "JSON decode failed"),
Self::MissingClaim => write!(f, "Missing required claim"),
}
}
}
impl std::error::Error for TokenDecodeError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActClaim {
@@ -12,8 +209,8 @@ pub struct Claims {
pub iss: String,
pub sub: String,
pub aud: String,
pub exp: usize,
pub iat: usize,
pub exp: i64,
pub iat: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -25,8 +222,8 @@ pub struct Claims {
#[derive(Debug, Serialize, Deserialize)]
pub struct Header {
pub alg: String,
pub typ: String,
pub alg: SigningAlgorithm,
pub typ: TokenType,
}
#[derive(Debug, Serialize, Deserialize)]
+61 -39
View File
@@ -1,8 +1,7 @@
use super::token::{
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
TOKEN_TYPE_REFRESH,
use super::types::{
Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
TokenVerifyError, UnsafeClaims,
};
use super::types::{Claims, Header, TokenData, TokenVerifyError, UnsafeClaims};
use anyhow::{Context, Result, anyhow};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
@@ -14,54 +13,54 @@ use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub fn get_did_from_token(token: &str) -> Result<String, String> {
pub fn get_did_from_token(token: &str) -> Result<String, TokenDecodeError> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err("Invalid token format".to_string());
return Err(TokenDecodeError::InvalidFormat);
}
let payload_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|e| format!("Base64 decode failed: {}", e))?;
.map_err(|_| TokenDecodeError::Base64DecodeFailed)?;
let claims: UnsafeClaims =
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
serde_json::from_slice(&payload_bytes).map_err(|_| TokenDecodeError::JsonDecodeFailed)?;
Ok(claims.sub.unwrap_or(claims.iss))
}
pub fn get_jti_from_token(token: &str) -> Result<String, String> {
pub fn get_jti_from_token(token: &str) -> Result<String, TokenDecodeError> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err("Invalid token format".to_string());
return Err(TokenDecodeError::InvalidFormat);
}
let payload_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|e| format!("Base64 decode failed: {}", e))?;
.map_err(|_| TokenDecodeError::Base64DecodeFailed)?;
let claims: serde_json::Value =
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
serde_json::from_slice(&payload_bytes).map_err(|_| TokenDecodeError::JsonDecodeFailed)?;
claims
.get("jti")
.and_then(|j| j.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "No jti claim in token".to_string())
.ok_or(TokenDecodeError::MissingClaim)
}
pub fn get_algorithm_from_token(token: &str) -> Result<String, String> {
pub fn get_algorithm_from_token(token: &str) -> Result<SigningAlgorithm, TokenDecodeError> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err("Invalid token format".to_string());
return Err(TokenDecodeError::InvalidFormat);
}
let header_bytes = URL_SAFE_NO_PAD
.decode(parts[0])
.map_err(|e| format!("Base64 decode failed: {}", e))?;
.map_err(|_| TokenDecodeError::Base64DecodeFailed)?;
let header: Header =
serde_json::from_slice(&header_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
serde_json::from_slice(&header_bytes).map_err(|_| TokenDecodeError::JsonDecodeFailed)?;
Ok(header.alg)
}
@@ -74,8 +73,12 @@ pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Cl
verify_token_internal(
token,
key_bytes,
Some(TOKEN_TYPE_ACCESS),
Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]),
Some(TokenType::Access),
Some(&[
TokenScope::Access,
TokenScope::AppPass,
TokenScope::AppPassPrivileged,
]),
)
}
@@ -83,8 +86,8 @@ pub fn verify_refresh_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<C
verify_token_internal(
token,
key_bytes,
Some(TOKEN_TYPE_REFRESH),
Some(&[SCOPE_REFRESH]),
Some(TokenType::Refresh),
Some(&[TokenScope::Refresh]),
)
}
@@ -92,8 +95,12 @@ pub fn verify_access_token_hs256(token: &str, secret: &[u8]) -> Result<TokenData
verify_token_hs256_internal(
token,
secret,
Some(TOKEN_TYPE_ACCESS),
Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]),
Some(TokenType::Access),
Some(&[
TokenScope::Access,
TokenScope::AppPass,
TokenScope::AppPassPrivileged,
]),
)
}
@@ -101,16 +108,16 @@ pub fn verify_refresh_token_hs256(token: &str, secret: &[u8]) -> Result<TokenDat
verify_token_hs256_internal(
token,
secret,
Some(TOKEN_TYPE_REFRESH),
Some(&[SCOPE_REFRESH]),
Some(TokenType::Refresh),
Some(&[TokenScope::Refresh]),
)
}
fn verify_token_internal(
token: &str,
key_bytes: &[u8],
expected_typ: Option<&str>,
allowed_scopes: Option<&[&str]>,
expected_typ: Option<TokenType>,
allowed_scopes: Option<&[TokenScope]>,
) -> Result<TokenData<Claims>> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
@@ -160,13 +167,18 @@ fn verify_token_internal(
let claims: Claims =
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
let now = Utc::now().timestamp() as usize;
let now = Utc::now().timestamp();
if claims.exp < now {
return Err(anyhow!("Token expired"));
}
if let Some(scopes) = allowed_scopes {
let token_scope = claims.scope.as_deref().unwrap_or("");
let token_scope: TokenScope = claims
.scope
.as_deref()
.unwrap_or("")
.parse()
.unwrap_or_else(|e| match e {});
if !scopes.contains(&token_scope) {
return Err(anyhow!("Invalid token scope: {}", token_scope));
}
@@ -178,8 +190,8 @@ fn verify_token_internal(
fn verify_token_hs256_internal(
token: &str,
secret: &[u8],
expected_typ: Option<&str>,
allowed_scopes: Option<&[&str]>,
expected_typ: Option<TokenType>,
allowed_scopes: Option<&[TokenScope]>,
) -> Result<TokenData<Claims>> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
@@ -197,7 +209,7 @@ fn verify_token_hs256_internal(
let header: Header =
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
if header.alg != "HS256" {
if header.alg != SigningAlgorithm::HS256 {
return Err(anyhow!("Expected HS256 algorithm, got {}", header.alg));
}
@@ -235,13 +247,18 @@ fn verify_token_hs256_internal(
let claims: Claims =
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
let now = Utc::now().timestamp() as usize;
let now = Utc::now().timestamp();
if claims.exp < now {
return Err(anyhow!("Token expired"));
}
if let Some(scopes) = allowed_scopes {
let token_scope = claims.scope.as_deref().unwrap_or("");
let token_scope: TokenScope = claims
.scope
.as_deref()
.unwrap_or("")
.parse()
.unwrap_or_else(|e| match e {});
if !scopes.contains(&token_scope) {
return Err(anyhow!("Invalid token scope: {}", token_scope));
}
@@ -254,14 +271,14 @@ pub fn verify_access_token_typed(
token: &str,
key_bytes: &[u8],
) -> Result<TokenData<Claims>, TokenVerifyError> {
verify_token_typed_internal(token, key_bytes, Some(TOKEN_TYPE_ACCESS), None)
verify_token_typed_internal(token, key_bytes, Some(TokenType::Access), None)
}
fn verify_token_typed_internal(
token: &str,
key_bytes: &[u8],
expected_typ: Option<&str>,
allowed_scopes: Option<&[&str]>,
expected_typ: Option<TokenType>,
allowed_scopes: Option<&[TokenScope]>,
) -> Result<TokenData<Claims>, TokenVerifyError> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
@@ -315,13 +332,18 @@ fn verify_token_typed_internal(
return Err(TokenVerifyError::Invalid);
};
let now = Utc::now().timestamp() as usize;
let now = Utc::now().timestamp();
if claims.exp < now {
return Err(TokenVerifyError::Expired);
}
if let Some(scopes) = allowed_scopes {
let token_scope = claims.scope.as_deref().unwrap_or("");
let token_scope: TokenScope = claims
.scope
.as_deref()
.unwrap_or("")
.parse()
.unwrap_or_else(|e| match e {});
if !scopes.contains(&token_scope) {
return Err(TokenVerifyError::Invalid);
}
+3 -3
View File
@@ -48,7 +48,7 @@ mod valkey {
.arg(key)
.arg(value)
.arg("PX")
.arg(ttl.as_millis().min(i64::MAX as u128) as i64)
.arg(i64::try_from(ttl.as_millis()).unwrap_or(i64::MAX))
.query_async::<()>(&mut conn)
.await
.map_err(|e| CacheError::Connection(e.to_string()))
@@ -94,7 +94,7 @@ mod valkey {
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool {
let mut conn = self.conn.clone();
let full_key = format!("rl:{}", key);
let window_secs = window_ms.div_ceil(1000).max(1) as i64;
let window_secs = i64::try_from(window_ms.div_ceil(1000).max(1)).unwrap_or(i64::MAX);
let result: Result<i64, _> = redis::Script::new(
r"local c = redis.call('INCR', KEYS[1])
if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
@@ -106,7 +106,7 @@ return c",
.invoke_async(&mut conn)
.await;
match result {
Ok(count) => count <= limit as i64,
Ok(count) => count <= i64::from(limit),
Err(e) => {
tracing::warn!(error = %e, "redis rate limit script failed, allowing request");
true
+48 -1
View File
@@ -1,13 +1,60 @@
use std::fmt;
use std::str::FromStr;
use async_trait::async_trait;
use tranquil_types::{AtUri, Nsid};
use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BacklinkPath {
Subject,
SubjectUri,
}
impl BacklinkPath {
pub fn as_str(&self) -> &'static str {
match self {
Self::Subject => "subject",
Self::SubjectUri => "subject.uri",
}
}
}
impl fmt::Display for BacklinkPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct BacklinkPathParseError(String);
impl fmt::Display for BacklinkPathParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "unknown backlink path: {}", self.0)
}
}
impl std::error::Error for BacklinkPathParseError {}
impl FromStr for BacklinkPath {
type Err = BacklinkPathParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"subject" => Ok(Self::Subject),
"subject.uri" => Ok(Self::SubjectUri),
_ => Err(BacklinkPathParseError(s.to_owned())),
}
}
}
#[derive(Debug, Clone)]
pub struct Backlink {
pub uri: AtUri,
pub path: String,
pub path: BacklinkPath,
pub link_to: String,
}
@@ -10,7 +10,7 @@ pub struct ChannelVerificationStatus {
}
impl ChannelVerificationStatus {
pub fn new(email: bool, discord: bool, telegram: bool, signal: bool) -> Self {
pub fn from_db_row(email: bool, discord: bool, telegram: bool, signal: bool) -> Self {
Self {
email,
discord,
@@ -19,6 +19,15 @@ impl ChannelVerificationStatus {
}
}
pub fn from_verified_channels(channels: &[CommsChannel]) -> Self {
Self {
email: channels.contains(&CommsChannel::Email),
discord: channels.contains(&CommsChannel::Discord),
telegram: channels.contains(&CommsChannel::Telegram),
signal: channels.contains(&CommsChannel::Signal),
}
}
pub fn has_any_verified(&self) -> bool {
self.email || self.discord || self.telegram || self.signal
}
+3
View File
@@ -26,6 +26,9 @@ pub enum DbError {
#[error("Resource busy, try again")]
LockContention,
#[error("Corrupt data in column: {0}")]
CorruptData(&'static str),
#[error("Other database error: {0}")]
Other(String),
}
+53 -17
View File
@@ -31,25 +31,16 @@ impl InviteCodeState {
}
}
impl From<bool> for InviteCodeState {
fn from(disabled: bool) -> Self {
if disabled {
Self::Disabled
} else {
Self::Active
impl InviteCodeState {
pub fn from_disabled_flag(disabled: bool) -> Self {
match disabled {
true => Self::Disabled,
false => Self::Active,
}
}
}
impl From<Option<bool>> for InviteCodeState {
fn from(disabled: Option<bool>) -> Self {
Self::from(disabled.unwrap_or(false))
}
}
impl From<InviteCodeState> for bool {
fn from(state: InviteCodeState) -> Self {
matches!(state, InviteCodeState::Disabled)
pub fn from_optional_disabled_flag(disabled: Option<bool>) -> Self {
Self::from_disabled_flag(disabled.unwrap_or(false))
}
}
@@ -63,6 +54,51 @@ pub enum CommsChannel {
Signal,
}
impl CommsChannel {
pub fn as_str(self) -> &'static str {
match self {
Self::Email => "email",
Self::Discord => "discord",
Self::Telegram => "telegram",
Self::Signal => "signal",
}
}
pub fn display_name(self) -> &'static str {
match self {
Self::Email => "email",
Self::Discord => "Discord",
Self::Telegram => "Telegram",
Self::Signal => "Signal",
}
}
}
impl std::str::FromStr for CommsChannel {
type Err = InvalidCommsChannel;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"email" => Ok(Self::Email),
"discord" => Ok(Self::Discord),
"telegram" => Ok(Self::Telegram),
"signal" => Ok(Self::Signal),
_ => Err(InvalidCommsChannel),
}
}
}
#[derive(Debug, Clone)]
pub struct InvalidCommsChannel;
impl std::fmt::Display for InvalidCommsChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("invalid comms channel")
}
}
impl std::error::Error for InvalidCommsChannel {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "comms_type", rename_all = "snake_case")]
pub enum CommsType {
@@ -139,7 +175,7 @@ pub struct InviteCodeRow {
impl InviteCodeRow {
pub fn state(&self) -> InviteCodeState {
InviteCodeState::from(self.disabled)
InviteCodeState::from_optional_disabled_flag(self.disabled)
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ mod session;
mod sso;
mod user;
pub use backlink::{Backlink, BacklinkRepository};
pub use backlink::{Backlink, BacklinkPath, BacklinkRepository};
pub use backup::{
BackupForDeletion, BackupRepository, BackupRow, BackupStorageInfo, BlobExportInfo,
OldBackupInfo, UserBackupInfo,
@@ -68,5 +68,5 @@ pub use user::{
UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck,
UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository, UserResendVerification,
UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, UserVerificationInfo, UserWithKey,
VerifiedTotpRecord,
VerifiedTotpRecord, WebauthnChallengeType,
};
+7
View File
@@ -57,6 +57,13 @@ impl AccountStatus {
}
}
pub fn for_firehose_typed(&self) -> Option<Self> {
match self {
Self::Active => None,
other => Some(*other),
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"active" => Some(Self::Active),
+9 -23
View File
@@ -20,17 +20,12 @@ impl LoginType {
pub fn is_modern(self) -> bool {
matches!(self, Self::Modern)
}
}
impl From<bool> for LoginType {
fn from(legacy: bool) -> Self {
if legacy { Self::Legacy } else { Self::Modern }
}
}
impl From<LoginType> for bool {
fn from(lt: LoginType) -> Self {
matches!(lt, LoginType::Legacy)
pub fn from_legacy_flag(legacy: bool) -> Self {
match legacy {
true => Self::Legacy,
false => Self::Modern,
}
}
}
@@ -45,24 +40,15 @@ impl AppPasswordPrivilege {
pub fn is_privileged(self) -> bool {
matches!(self, Self::Privileged)
}
}
impl From<bool> for AppPasswordPrivilege {
fn from(privileged: bool) -> Self {
if privileged {
Self::Privileged
} else {
Self::Standard
pub fn from_privileged_flag(privileged: bool) -> Self {
match privileged {
true => Self::Privileged,
false => Self::Standard,
}
}
}
impl From<AppPasswordPrivilege> for bool {
fn from(p: AppPasswordPrivilege) -> Self {
matches!(p, AppPasswordPrivilege::Privileged)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SessionId(i32);
+41 -2
View File
@@ -113,6 +113,7 @@ impl From<ExternalEmail> for String {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "sso_provider_type", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum SsoProviderType {
Github,
Discord,
@@ -141,7 +142,7 @@ impl SsoAction {
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
match s {
"login" => Some(Self::Login),
"link" => Some(Self::Link),
"register" => Some(Self::Register),
@@ -156,6 +157,44 @@ impl std::fmt::Display for SsoAction {
}
}
impl std::str::FromStr for SsoProviderType {
type Err = InvalidSsoProviderType;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s).ok_or(InvalidSsoProviderType)
}
}
#[derive(Debug, Clone)]
pub struct InvalidSsoProviderType;
impl std::fmt::Display for InvalidSsoProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("invalid SSO provider type")
}
}
impl std::error::Error for InvalidSsoProviderType {}
impl std::str::FromStr for SsoAction {
type Err = InvalidSsoAction;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s).ok_or(InvalidSsoAction)
}
}
#[derive(Debug, Clone)]
pub struct InvalidSsoAction;
impl std::fmt::Display for InvalidSsoAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("invalid SSO action")
}
}
impl std::error::Error for InvalidSsoAction {}
impl SsoProviderType {
pub fn as_str(&self) -> &'static str {
match self {
@@ -169,7 +208,7 @@ impl SsoProviderType {
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
match s {
"github" => Some(Self::Github),
"discord" => Some(Self::Discord),
"google" => Some(Self::Google),
+18 -3
View File
@@ -6,6 +6,21 @@ use uuid::Uuid;
use crate::{ChannelVerificationStatus, CommsChannel, DbError, SsoProviderType};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebauthnChallengeType {
Registration,
Authentication,
}
impl WebauthnChallengeType {
pub fn as_str(self) -> &'static str {
match self {
Self::Registration => "registration",
Self::Authentication => "authentication",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "account_type", rename_all = "snake_case")]
pub enum AccountType {
@@ -325,20 +340,20 @@ pub trait UserRepository: Send + Sync {
async fn save_webauthn_challenge(
&self,
did: &Did,
challenge_type: &str,
challenge_type: WebauthnChallengeType,
state_json: &str,
) -> Result<Uuid, DbError>;
async fn load_webauthn_challenge(
&self,
did: &Did,
challenge_type: &str,
challenge_type: WebauthnChallengeType,
) -> Result<Option<String>, DbError>;
async fn delete_webauthn_challenge(
&self,
did: &Did,
challenge_type: &str,
challenge_type: WebauthnChallengeType,
) -> Result<(), DbError>;
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError>;
+4 -4
View File
@@ -261,7 +261,7 @@ impl InfraRepository for PostgresInfraRepository {
.map(|r| InviteCodeInfo {
code: r.code,
available_uses: r.available_uses,
state: InviteCodeState::from(r.disabled),
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: None,
@@ -438,7 +438,7 @@ impl InfraRepository for PostgresInfraRepository {
.map(|r| InviteCodeInfo {
code: r.code,
available_uses: r.available_uses,
state: InviteCodeState::from(r.disabled),
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
@@ -461,7 +461,7 @@ impl InfraRepository for PostgresInfraRepository {
Ok(result.map(|r| InviteCodeInfo {
code: r.code,
available_uses: r.available_uses,
state: InviteCodeState::from(r.disabled),
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
@@ -492,7 +492,7 @@ impl InfraRepository for PostgresInfraRepository {
InviteCodeInfo {
code: r.code,
available_uses: r.available_uses,
state: InviteCodeState::from(r.disabled),
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
+7 -7
View File
@@ -37,7 +37,7 @@ impl SessionRepository for PostgresSessionRepository {
data.refresh_jti,
data.access_expires_at,
data.refresh_expires_at,
bool::from(data.login_type),
data.login_type.is_legacy(),
data.mfa_verified,
data.scope,
data.controller_did.as_ref().map(|d| d.as_str()),
@@ -75,7 +75,7 @@ impl SessionRepository for PostgresSessionRepository {
refresh_jti: r.refresh_jti,
access_expires_at: r.access_expires_at,
refresh_expires_at: r.refresh_expires_at,
login_type: LoginType::from(r.legacy_login),
login_type: LoginType::from_legacy_flag(r.legacy_login),
mfa_verified: r.mfa_verified,
scope: r.scope,
controller_did: r.controller_did.map(Did::from),
@@ -325,7 +325,7 @@ impl SessionRepository for PostgresSessionRepository {
name: r.name,
password_hash: r.password_hash,
created_at: r.created_at,
privilege: AppPasswordPrivilege::from(r.privileged),
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
scopes: r.scopes,
created_by_controller_did: r.created_by_controller_did.map(Did::from),
})
@@ -358,7 +358,7 @@ impl SessionRepository for PostgresSessionRepository {
name: r.name,
password_hash: r.password_hash,
created_at: r.created_at,
privilege: AppPasswordPrivilege::from(r.privileged),
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
scopes: r.scopes,
created_by_controller_did: r.created_by_controller_did.map(Did::from),
})
@@ -389,7 +389,7 @@ impl SessionRepository for PostgresSessionRepository {
name: r.name,
password_hash: r.password_hash,
created_at: r.created_at,
privilege: AppPasswordPrivilege::from(r.privileged),
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
scopes: r.scopes,
created_by_controller_did: r.created_by_controller_did.map(Did::from),
}))
@@ -405,7 +405,7 @@ impl SessionRepository for PostgresSessionRepository {
data.user_id,
data.name,
data.password_hash,
bool::from(data.privilege),
data.privilege.is_privileged(),
data.scopes,
data.created_by_controller_did.as_ref().map(|d| d.as_str())
)
@@ -486,7 +486,7 @@ impl SessionRepository for PostgresSessionRepository {
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SessionMfaStatus {
login_type: LoginType::from(r.legacy_login),
login_type: LoginType::from_legacy_flag(r.legacy_login),
mfa_verified: r.mfa_verified,
last_reauth_at: r.last_reauth_at,
}))
+37 -26
View File
@@ -68,17 +68,20 @@ impl SsoRepository for PostgresSsoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| ExternalIdentity {
id: r.id,
did: unsafe { Did::new_unchecked(&r.did) },
provider: r.provider,
provider_user_id: ExternalUserId::from(r.provider_user_id),
provider_username: r.provider_username.map(ExternalUsername::from),
provider_email: r.provider_email.map(ExternalEmail::from),
created_at: r.created_at,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
}))
row.map(|r| {
Ok(ExternalIdentity {
id: r.id,
did: r.did.parse().map_err(|_| DbError::CorruptData("DID"))?,
provider: r.provider,
provider_user_id: ExternalUserId::from(r.provider_user_id),
provider_username: r.provider_username.map(ExternalUsername::from),
provider_email: r.provider_email.map(ExternalEmail::from),
created_at: r.created_at,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
})
})
.transpose()
}
async fn get_external_identities_by_did(
@@ -99,20 +102,21 @@ impl SsoRepository for PostgresSsoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| ExternalIdentity {
id: r.id,
did: unsafe { Did::new_unchecked(&r.did) },
provider: r.provider,
provider_user_id: ExternalUserId::from(r.provider_user_id),
provider_username: r.provider_username.map(ExternalUsername::from),
provider_email: r.provider_email.map(ExternalEmail::from),
created_at: r.created_at,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
rows.into_iter()
.map(|r| {
Ok(ExternalIdentity {
id: r.id,
did: r.did.parse().map_err(|_| DbError::CorruptData("DID"))?,
provider: r.provider,
provider_user_id: ExternalUserId::from(r.provider_user_id),
provider_username: r.provider_username.map(ExternalUsername::from),
provider_email: r.provider_email.map(ExternalEmail::from),
created_at: r.created_at,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
})
})
.collect())
.collect()
}
async fn update_external_identity_login(
@@ -202,7 +206,10 @@ impl SsoRepository for PostgresSsoRepository {
.map_err(map_sqlx_error)?;
row.map(|r| {
let action = SsoAction::parse(&r.action).ok_or(DbError::NotFound)?;
let action: SsoAction = r
.action
.parse()
.map_err(|_| DbError::CorruptData("sso_action"))?;
Ok(SsoAuthState {
state: r.state,
request_uri: r.request_uri,
@@ -210,7 +217,11 @@ impl SsoRepository for PostgresSsoRepository {
action,
nonce: r.nonce,
code_verifier: r.code_verifier,
did: r.did.map(|d| unsafe { Did::new_unchecked(&d) }),
did: r
.did
.map(|d| d.parse::<Did>())
.transpose()
.map_err(|_| DbError::CorruptData("DID"))?,
created_at: r.created_at,
expires_at: r.expires_at,
})
+14 -14
View File
@@ -14,7 +14,7 @@ use tranquil_db_traits::{
UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref,
UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository,
UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus,
UserVerificationInfo, UserWithKey,
UserVerificationInfo, UserWithKey, WebauthnChallengeType,
};
pub struct PostgresUserRepository {
@@ -281,7 +281,7 @@ impl UserRepository for PostgresUserRepository {
password_hash: r.password_hash,
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
channel_verification: ChannelVerificationStatus::new(
channel_verification: ChannelVerificationStatus::from_db_row(
r.email_verified,
r.discord_verified,
r.telegram_verified,
@@ -746,7 +746,7 @@ impl UserRepository for PostgresUserRepository {
id: r.id,
handle: Handle::from(r.handle),
email: r.email,
channel_verification: ChannelVerificationStatus::new(
channel_verification: ChannelVerificationStatus::from_db_row(
r.email_verified,
r.discord_verified,
r.telegram_verified,
@@ -1029,7 +1029,7 @@ impl UserRepository for PostgresUserRepository {
async fn save_webauthn_challenge(
&self,
did: &Did,
challenge_type: &str,
challenge_type: WebauthnChallengeType,
state_json: &str,
) -> Result<Uuid, DbError> {
let id = Uuid::new_v4();
@@ -1041,7 +1041,7 @@ impl UserRepository for PostgresUserRepository {
id,
did.as_str(),
challenge,
challenge_type,
challenge_type.as_str(),
state_json,
expires_at,
)
@@ -1055,14 +1055,14 @@ impl UserRepository for PostgresUserRepository {
async fn load_webauthn_challenge(
&self,
did: &Did,
challenge_type: &str,
challenge_type: WebauthnChallengeType,
) -> Result<Option<String>, DbError> {
let row = sqlx::query_scalar!(
r#"SELECT state_json FROM webauthn_challenges
WHERE did = $1 AND challenge_type = $2 AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1"#,
did.as_str(),
challenge_type
challenge_type.as_str()
)
.fetch_optional(&self.pool)
.await
@@ -1074,12 +1074,12 @@ impl UserRepository for PostgresUserRepository {
async fn delete_webauthn_challenge(
&self,
did: &Did,
challenge_type: &str,
challenge_type: WebauthnChallengeType,
) -> Result<(), DbError> {
sqlx::query!(
"DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = $2",
did.as_str(),
challenge_type
challenge_type.as_str()
)
.execute(&self.pool)
.await
@@ -1365,7 +1365,7 @@ impl UserRepository for PostgresUserRepository {
preferred_comms_channel: row.preferred_comms_channel,
deactivated_at: row.deactivated_at,
takedown_ref: row.takedown_ref,
channel_verification: ChannelVerificationStatus::new(
channel_verification: ChannelVerificationStatus::from_db_row(
row.email_verified,
row.discord_verified,
row.telegram_verified,
@@ -1395,7 +1395,7 @@ impl UserRepository for PostgresUserRepository {
id: row.id,
two_factor_enabled: row.two_factor_enabled,
preferred_comms_channel: row.preferred_comms_channel,
channel_verification: ChannelVerificationStatus::new(
channel_verification: ChannelVerificationStatus::from_db_row(
row.email_verified,
row.discord_verified,
row.telegram_verified,
@@ -1432,7 +1432,7 @@ impl UserRepository for PostgresUserRepository {
takedown_ref: row.takedown_ref,
preferred_locale: row.preferred_locale,
preferred_comms_channel: row.preferred_comms_channel,
channel_verification: ChannelVerificationStatus::new(
channel_verification: ChannelVerificationStatus::from_db_row(
row.email_verified,
row.discord_verified,
row.telegram_verified,
@@ -1525,7 +1525,7 @@ impl UserRepository for PostgresUserRepository {
email: row.email,
deactivated_at: row.deactivated_at,
takedown_ref: row.takedown_ref,
channel_verification: ChannelVerificationStatus::new(
channel_verification: ChannelVerificationStatus::from_db_row(
row.email_verified,
row.discord_verified,
row.telegram_verified,
@@ -1602,7 +1602,7 @@ impl UserRepository for PostgresUserRepository {
discord_username: row.discord_username,
telegram_username: row.telegram_username,
signal_username: row.signal_username,
channel_verification: ChannelVerificationStatus::new(
channel_verification: ChannelVerificationStatus::from_db_row(
row.email_verified,
row.discord_verified,
row.telegram_verified,
@@ -21,7 +21,7 @@ fn get_age_from_datestring(birth_date: &str) -> Option<i32> {
let bday = NaiveDate::parse_from_str(birth_date, "%Y-%m-%d").ok()?;
let today = Utc::now().date_naive();
let mut age = today.year() - bday.year();
let m = today.month() as i32 - bday.month() as i32;
let m = i32::try_from(today.month()).unwrap_or(0) - i32::try_from(bday.month()).unwrap_or(0);
if m < 0 || (m == 0 && today.day() < bday.day()) {
age -= 1;
}
@@ -48,6 +48,9 @@ pub async fn delete_account(
did, e
);
}
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
let _ = state
.cache
.delete(&crate::cache_keys::handle_key(&handle))
.await;
Ok(EmptyResponse::ok().into_response())
}
@@ -1,4 +1,4 @@
use crate::api::error::{ApiError, AtpJson, DbResultExt};
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Admin, Auth};
use crate::state::AppState;
use crate::types::Did;
@@ -30,7 +30,7 @@ pub struct SendEmailOutput {
pub async fn send_email(
State(state): State<AppState>,
_auth: Auth<Admin>,
AtpJson(input): AtpJson<SendEmailInput>,
Json(input): Json<SendEmailInput>,
) -> Result<Response, ApiError> {
let content = input.content.trim();
if content.is_empty() {
@@ -67,10 +67,11 @@ pub async fn search_accounts(
.await
.log_db_err("in search_accounts")?;
let has_more = rows.len() > limit as usize;
let limit_usize = usize::try_from(limit).unwrap_or(0);
let has_more = rows.len() > limit_usize;
let accounts: Vec<AccountView> = rows
.into_iter()
.take(limit as usize)
.take(limit_usize)
.map(|row| AccountView {
did: row.did.clone(),
handle: row.handle,
@@ -84,7 +84,7 @@ pub async fn update_account_handle(
.ok()
.flatten()
.ok_or(ApiError::AccountNotFound)?;
let handle_for_check = unsafe { Handle::new_unchecked(&handle) };
let handle_for_check: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
if let Ok(true) = state
.user_repo
.check_handle_exists(&handle_for_check, user_id)
@@ -100,9 +100,15 @@ pub async fn update_account_handle(
Ok(0) => Err(ApiError::AccountNotFound),
Ok(_) => {
if let Some(old) = old_handle {
let _ = state.cache.delete(&format!("handle:{}", old)).await;
let _ = state
.cache
.delete(&crate::cache_keys::handle_key(&old))
.await;
}
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
let _ = state
.cache
.delete(&crate::cache_keys::handle_key(&handle))
.await;
if let Err(e) = crate::api::repo::record::sequence_identity_event(
&state,
did,
+18 -9
View File
@@ -3,7 +3,7 @@ use crate::auth::{Admin, Auth};
use crate::state::AppState;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use tracing::error;
use tracing::{error, warn};
use tranquil_types::CidLink;
#[derive(Serialize)]
@@ -187,15 +187,24 @@ pub async fn update_server_config(
};
if let Some(old_cid_str) = should_delete_old {
let old_cid = unsafe { CidLink::new_unchecked(old_cid_str) };
if let Ok(Some(storage_key)) =
state.infra_repo.get_blob_storage_key_by_cid(&old_cid).await
{
if let Err(e) = state.blob_store.delete(&storage_key).await {
error!("Failed to delete old logo blob from storage: {:?}", e);
match CidLink::new(old_cid_str) {
Ok(old_cid) => {
if let Ok(Some(storage_key)) =
state.infra_repo.get_blob_storage_key_by_cid(&old_cid).await
{
if let Err(e) = state.blob_store.delete(&storage_key).await {
error!("Failed to delete old logo blob from storage: {:?}", e);
}
if let Err(e) = state.infra_repo.delete_blob_by_cid(&old_cid).await {
error!("Failed to delete old logo blob record: {:?}", e);
}
}
}
if let Err(e) = state.infra_repo.delete_blob_by_cid(&old_cid).await {
error!("Failed to delete old logo blob record: {:?}", e);
Err(e) => {
warn!(
"Old logo CID in database is invalid, skipping cleanup: {:?}",
e
);
}
}
}
+1 -1
View File
@@ -144,7 +144,7 @@ pub async fn get_invite_codes(
})
.collect();
let next_cursor = if codes_rows.len() == limit as usize {
let next_cursor = if codes_rows.len() == usize::try_from(limit).unwrap_or(0) {
codes_rows.last().map(|r| r.code.clone())
} else {
None
+8 -2
View File
@@ -175,7 +175,10 @@ pub async fn update_subject_status(
Some("com.atproto.admin.defs#repoRef") => {
let did_str = input.subject.get("did").and_then(|d| d.as_str());
if let Some(did_str) = did_str {
let did = unsafe { Did::new_unchecked(did_str) };
let did: Did = match did_str.parse() {
Ok(d) => d,
Err(_) => return Err(ApiError::InvalidDid("Invalid DID format".into())),
};
if let Some(takedown) = &input.takedown {
let takedown_ref = if takedown.applied {
takedown.r#ref.as_deref()
@@ -230,7 +233,10 @@ pub async fn update_subject_status(
}
}
if let Ok(Some(handle)) = state.user_repo.get_handle_by_did(&did).await {
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
let _ = state
.cache
.delete(&crate::cache_keys::handle_key(&handle))
.await;
}
return Ok((
StatusCode::OK,
+7 -8
View File
@@ -1,9 +1,9 @@
use crate::auth::{extract_auth_token_from_header, validate_token_with_dpop};
use crate::auth::{AccountRequirement, extract_auth_token_from_header, validate_token_with_dpop};
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
http::{HeaderMap, Method, StatusCode},
response::{IntoResponse, Response},
};
use serde_json::json;
@@ -33,25 +33,24 @@ pub async fn get_age_assurance_state() -> Response {
}
async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option<String> {
let auth_header = crate::util::get_header_str(headers, "Authorization");
let auth_header = crate::util::get_header_str(headers, http::header::AUTHORIZATION);
tracing::debug!(?auth_header, "age assurance: extracting token");
let extracted = extract_auth_token_from_header(auth_header)?;
tracing::debug!("age assurance: got token, validating");
let dpop_proof = crate::util::get_header_str(headers, "DPoP");
let dpop_proof = crate::util::get_header_str(headers, crate::util::HEADER_DPOP);
let http_uri = "/";
let auth_user = match validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
extracted.is_dpop,
extracted.scheme,
dpop_proof,
"GET",
Method::GET.as_str(),
http_uri,
false,
false,
AccountRequirement::Active,
)
.await
{
+6 -5
View File
@@ -4,6 +4,7 @@ use crate::auth::{Active, Auth};
use crate::scheduled::generate_full_backup;
use crate::state::AppState;
use crate::storage::{BackupStorage, backup_retention_count};
use anyhow::Context;
use axum::{
Json,
extract::{Query, State},
@@ -213,7 +214,7 @@ pub async fn create_backup(
};
let block_count = crate::scheduled::count_car_blocks(&car_bytes);
let size_bytes = car_bytes.len() as i64;
let size_bytes = i64::try_from(car_bytes.len()).unwrap_or(i64::MAX);
let storage_key = match backup_storage
.put_backup(&user.did, &repo_rev, &car_bytes)
@@ -292,11 +293,11 @@ async fn cleanup_old_backups(
backup_storage: &dyn BackupStorage,
user_id: uuid::Uuid,
retention_count: u32,
) -> Result<(), String> {
) -> anyhow::Result<()> {
let old_backups: Vec<OldBackupInfo> = backup_repo
.get_old_backups(user_id, retention_count as i64)
.get_old_backups(user_id, i64::from(retention_count))
.await
.map_err(|e| format!("DB error fetching old backups: {}", e))?;
.context("DB error fetching old backups")?;
for backup in old_backups {
if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await {
@@ -311,7 +312,7 @@ async fn cleanup_old_backups(
backup_repo
.delete_backup(backup.id)
.await
.map_err(|e| format!("Failed to delete old backup record: {}", e))?;
.context("Failed to delete old backup record")?;
}
Ok(())
+12 -11
View File
@@ -7,7 +7,7 @@ use crate::delegation::{
};
use crate::rate_limit::{AccountCreationLimit, RateLimited};
use crate::state::AppState;
use crate::types::{Did, Handle, Nsid, Rkey};
use crate::types::{Did, Handle};
use crate::util::{pds_hostname, pds_hostname_without_port};
use axum::{
Json,
@@ -164,7 +164,9 @@ pub async fn remove_controller(
.session_repo
.delete_app_passwords_by_controller(&auth.did, &input.controller_did)
.await
.unwrap_or(0) as usize;
.unwrap_or(0)
.try_into()
.unwrap_or(0usize);
let revoked_oauth_tokens = state
.oauth_repo
@@ -473,9 +475,7 @@ pub async fn create_delegated_account(
Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()),
}
} else {
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
if invite_required {
return Ok(ApiError::InviteCodeRequired.into_response());
}
@@ -529,8 +529,11 @@ pub async fn create_delegated_account(
.into_response());
}
let did = unsafe { Did::new_unchecked(&genesis_result.did) };
let handle = unsafe { Handle::new_unchecked(&handle) };
let did: Did = genesis_result
.did
.parse()
.map_err(|_| ApiError::InternalError(Some("PLC genesis returned invalid DID".into())))?;
let handle: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account");
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
@@ -627,13 +630,11 @@ pub async fn create_delegated_account(
"$type": "app.bsky.actor.profile",
"displayName": handle
});
let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") };
let profile_rkey = unsafe { Rkey::new_unchecked("self") };
if let Err(e) = crate::api::repo::record::create_record_internal(
&state,
&did,
&profile_collection,
&profile_rkey,
&crate::types::PROFILE_COLLECTION,
&crate::types::PROFILE_RKEY,
&profile_record,
)
.await
@@ -183,7 +183,7 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
"discord",
tranquil_db_traits::CommsChannel::Discord,
&discord_user_id,
pds_hostname(),
)
+13 -68
View File
@@ -1,10 +1,9 @@
use axum::{
Json,
extract::{FromRequest, Request, rejection::JsonRejection},
http::StatusCode,
http::{HeaderValue, StatusCode},
response::{IntoResponse, Response},
};
use serde::{Serialize, de::DeserializeOwned};
use serde::Serialize;
use std::borrow::Cow;
#[derive(Debug, Serialize)]
@@ -103,7 +102,7 @@ pub enum ApiError {
UpstreamTimeout,
UpstreamUnavailable(String),
UpstreamError {
status: u16,
status: StatusCode,
error: Option<String>,
message: Option<String>,
},
@@ -127,9 +126,7 @@ impl ApiError {
}
Self::ServiceUnavailable(_) | Self::BackupsDisabled => StatusCode::SERVICE_UNAVAILABLE,
Self::UpstreamTimeout => StatusCode::GATEWAY_TIMEOUT,
Self::UpstreamError { status, .. } => {
StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY)
}
Self::UpstreamError { status, .. } => *status,
Self::AuthenticationRequired
| Self::AuthenticationFailed(_)
| Self::AccountDeactivated
@@ -451,7 +448,7 @@ impl ApiError {
_ => None,
}
}
pub fn from_upstream_response(status: u16, body: &[u8]) -> Self {
pub fn from_upstream_response(status: StatusCode, body: &[u8]) -> Self {
if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(body) {
let error = parsed
.get("error")
@@ -485,18 +482,18 @@ impl IntoResponse for ApiError {
match &self {
Self::ExpiredToken(_) => {
response.headers_mut().insert(
"WWW-Authenticate",
"Bearer error=\"invalid_token\", error_description=\"Token has expired\""
.parse()
.unwrap(),
http::header::WWW_AUTHENTICATE,
HeaderValue::from_static(
"Bearer error=\"invalid_token\", error_description=\"Token has expired\"",
),
);
}
Self::OAuthExpiredToken(_) => {
response.headers_mut().insert(
"WWW-Authenticate",
"DPoP error=\"invalid_token\", error_description=\"Token has expired\""
.parse()
.unwrap(),
http::header::WWW_AUTHENTICATE,
HeaderValue::from_static(
"DPoP error=\"invalid_token\", error_description=\"Token has expired\"",
),
);
}
_ => {}
@@ -723,58 +720,6 @@ pub fn parse_did_option(s: Option<&str>) -> Result<Option<tranquil_types::Did>,
s.map(parse_did).transpose()
}
pub struct AtpJson<T>(pub T);
impl<T, S> FromRequest<S> for AtpJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = (StatusCode, Json<serde_json::Value>);
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
match Json::<T>::from_request(req, state).await {
Ok(Json(value)) => Ok(AtpJson(value)),
Err(rejection) => {
let message = extract_json_error_message(&rejection);
Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidRequest",
"message": message
})),
))
}
}
}
}
fn extract_json_error_message(rejection: &JsonRejection) -> String {
match rejection {
JsonRejection::JsonDataError(e) => {
let inner = e.body_text();
if inner.contains("missing field") {
let field = inner
.split("missing field `")
.nth(1)
.and_then(|s| s.split('`').next())
.unwrap_or("unknown");
format!("Missing required field: {}", field)
} else if inner.contains("invalid type") {
format!("Invalid field type: {}", inner)
} else {
inner
}
}
JsonRejection::JsonSyntaxError(_) => "Invalid JSON syntax".to_string(),
JsonRejection::MissingJsonContentType(_) => {
"Content-Type must be application/json".to_string()
}
JsonRejection::BytesRejection(_) => "Failed to read request body".to_string(),
_ => "Invalid request body".to_string(),
}
}
pub trait DbResultExt<T> {
fn log_db_err(self, ctx: &str) -> Result<T, ApiError>;
}
+56 -55
View File
@@ -5,7 +5,7 @@ use crate::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_servi
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
use crate::rate_limit::{AccountCreationLimit, RateLimited};
use crate::state::AppState;
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
use crate::types::{Did, Handle, PlainPassword};
use crate::util::{pds_hostname, pds_hostname_without_port};
use crate::validation::validate_password;
use axum::{
@@ -34,7 +34,7 @@ pub struct CreateAccountInput {
pub did: Option<String>,
pub did_type: Option<String>,
pub signing_key: Option<String>,
pub verification_channel: Option<String>,
pub verification_channel: Option<tranquil_db_traits::CommsChannel>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_username: Option<String>,
@@ -50,7 +50,7 @@ pub struct CreateAccountOutput {
pub access_jwt: String,
pub refresh_jwt: String,
pub verification_required: bool,
pub verification_channel: String,
pub verification_channel: tranquil_db_traits::CommsChannel,
}
pub async fn create_account(
@@ -73,9 +73,9 @@ pub async fn create_account(
info!("create_account called");
}
let migration_auth = if let Some(extracted) =
extract_auth_token_from_header(crate::util::get_header_str(&headers, "Authorization"))
{
let migration_auth = if let Some(extracted) = extract_auth_token_from_header(
crate::util::get_header_str(&headers, http::header::AUTHORIZATION),
) {
let token = extracted.token;
if is_service_token(&token) {
let verifier = ServiceTokenVerifier::new();
@@ -190,20 +190,18 @@ pub async fn create_account(
{
return ApiError::InvalidEmail.into_response();
}
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
let valid_channels = ["email", "discord", "telegram", "signal"];
if !valid_channels.contains(&verification_channel) && !is_migration {
return ApiError::InvalidVerificationChannel.into_response();
}
let verification_channel = input
.verification_channel
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
let verification_recipient = if is_migration {
None
} else {
Some(match verification_channel {
"email" => match &input.email {
tranquil_db_traits::CommsChannel::Email => match &input.email {
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
_ => return ApiError::MissingEmail.into_response(),
},
"discord" => match &input.discord_username {
tranquil_db_traits::CommsChannel::Discord => match &input.discord_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().to_lowercase();
if !crate::api::validation::is_valid_discord_username(&clean) {
@@ -215,7 +213,7 @@ pub async fn create_account(
}
_ => return ApiError::MissingDiscordId.into_response(),
},
"telegram" => match &input.telegram_username {
tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().trim_start_matches('@');
if !crate::api::validation::is_valid_telegram_username(clean) {
@@ -227,13 +225,12 @@ pub async fn create_account(
}
_ => return ApiError::MissingTelegramUsername.into_response(),
},
"signal" => match &input.signal_username {
tranquil_db_traits::CommsChannel::Signal => match &input.signal_username {
Some(username) if !username.trim().is_empty() => {
username.trim().trim_start_matches('@').to_lowercase()
}
_ => return ApiError::MissingSignalNumber.into_response(),
},
_ => return ApiError::InvalidVerificationChannel.into_response(),
})
};
let hostname = pds_hostname();
@@ -304,7 +301,7 @@ pub async fn create_account(
&& let Err(e) =
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()).await
{
return ApiError::InvalidDid(e).into_response();
return ApiError::InvalidDid(e.to_string()).into_response();
}
info!(did = %d, "Creating external did:web account");
d.clone()
@@ -320,7 +317,7 @@ pub async fn create_account(
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref())
.await
{
return ApiError::InvalidDid(e).into_response();
return ApiError::InvalidDid(e.to_string()).into_response();
}
d.clone()
} else if !d.trim().is_empty() {
@@ -397,9 +394,17 @@ pub async fn create_account(
}
};
if is_migration {
let did_typed: Did = match did.parse() {
Ok(d) => d,
Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(),
};
let handle_typed: Handle = match handle.parse() {
Ok(h) => h,
Err(_) => return ApiError::InvalidHandle(None).into_response(),
};
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
did: unsafe { Did::new_unchecked(&did) },
new_handle: unsafe { Handle::new_unchecked(&handle) },
did: did_typed.clone(),
new_handle: handle_typed.clone(),
new_email: email.clone(),
};
match state
@@ -453,7 +458,7 @@ pub async fn create_account(
}
};
let session_data = tranquil_db_traits::SessionTokenCreate {
did: unsafe { Did::new_unchecked(&did) },
did: did_typed.clone(),
access_jti: access_meta.jti.clone(),
refresh_jti: refresh_meta.jti.clone(),
access_expires_at: access_meta.expires_at,
@@ -470,8 +475,9 @@ pub async fn create_account(
}
let hostname = pds_hostname();
let verification_required = if let Some(ref user_email) = email {
let token =
crate::auth::verification_token::generate_migration_token(&did, user_email);
let token = crate::auth::verification_token::generate_migration_token(
&did_typed, user_email,
);
let formatted_token =
crate::auth::verification_token::format_token_for_display(&token);
if let Err(e) = crate::comms::comms_repo::enqueue_migration_verification(
@@ -494,12 +500,12 @@ pub async fn create_account(
axum::http::StatusCode::OK,
Json(CreateAccountOutput {
handle: handle.clone().into(),
did: unsafe { Did::new_unchecked(&did) },
did: did_typed.clone(),
did_doc: state.did_resolver.resolve_did_document(&did).await,
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
verification_required,
verification_channel: "email".to_string(),
verification_channel: tranquil_db_traits::CommsChannel::Email,
}),
)
.into_response();
@@ -518,7 +524,10 @@ pub async fn create_account(
}
}
let handle_typed = unsafe { Handle::new_unchecked(&handle) };
let handle_typed: Handle = match handle.parse() {
Ok(h) => h,
Err(_) => return ApiError::InvalidHandle(None).into_response(),
};
let handle_available = match state
.user_repo
.check_handle_available_for_new_account(&handle_typed)
@@ -534,9 +543,7 @@ pub async fn create_account(
return ApiError::HandleTaken.into_response();
}
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let invite_code_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
if invite_code_required
&& input
.invite_code
@@ -602,7 +609,10 @@ pub async fn create_account(
}
};
let rev = Tid::now(LimitedU32::MIN);
let did_for_commit = unsafe { Did::new_unchecked(&did) };
let did_for_commit: Did = match did.parse() {
Ok(d) => d,
Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(),
};
let (commit_bytes, _sig) =
match create_signed_commit(&did_for_commit, mst_root, rev.as_ref(), None, &signing_key) {
Ok(result) => result,
@@ -629,18 +639,12 @@ pub async fn create_account(
})
});
let preferred_comms_channel = match verification_channel {
"email" => tranquil_db_traits::CommsChannel::Email,
"discord" => tranquil_db_traits::CommsChannel::Discord,
"telegram" => tranquil_db_traits::CommsChannel::Telegram,
"signal" => tranquil_db_traits::CommsChannel::Signal,
_ => tranquil_db_traits::CommsChannel::Email,
};
let preferred_comms_channel = verification_channel;
let create_input = tranquil_db_traits::CreatePasswordAccountInput {
handle: unsafe { Handle::new_unchecked(&handle) },
handle: handle_typed.clone(),
email: email.clone(),
did: unsafe { Did::new_unchecked(&did) },
did: did_for_commit.clone(),
password_hash,
preferred_comms_channel,
discord_username: input
@@ -689,11 +693,9 @@ pub async fn create_account(
};
let user_id = create_result.user_id;
if !is_migration && !is_did_web_byod {
let did_typed = unsafe { Did::new_unchecked(&did) };
let handle_typed = unsafe { Handle::new_unchecked(&handle) };
if let Err(e) = crate::api::repo::record::sequence_identity_event(
&state,
&did_typed,
&did_for_commit,
Some(&handle_typed),
)
.await
@@ -702,7 +704,7 @@ pub async fn create_account(
}
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
&did_typed,
&did_for_commit,
tranquil_db_traits::AccountStatus::Active,
)
.await
@@ -711,7 +713,7 @@ pub async fn create_account(
}
if let Err(e) = crate::api::repo::record::sequence_genesis_commit(
&state,
&did_typed,
&did_for_commit,
&commit_cid,
&mst_root,
&rev_str,
@@ -722,7 +724,7 @@ pub async fn create_account(
}
if let Err(e) = crate::api::repo::record::sequence_sync_event(
&state,
&did_typed,
&did_for_commit,
&commit_cid_str,
Some(rev.as_ref()),
)
@@ -734,13 +736,11 @@ pub async fn create_account(
"$type": "app.bsky.actor.profile",
"displayName": input.handle
});
let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") };
let profile_rkey = unsafe { Rkey::new_unchecked("self") };
if let Err(e) = crate::api::repo::record::create_record_internal(
&state,
&did_typed,
&profile_collection,
&profile_rkey,
&did_for_commit,
&crate::types::PROFILE_COLLECTION,
&crate::types::PROFILE_RKEY,
&profile_record,
)
.await
@@ -752,7 +752,7 @@ pub async fn create_account(
if !is_migration {
if let Some(ref recipient) = verification_recipient {
let verification_token = crate::auth::verification_token::generate_signup_token(
&did,
&did_for_commit,
verification_channel,
recipient,
);
@@ -776,7 +776,8 @@ pub async fn create_account(
}
}
} else if let Some(ref user_email) = email {
let token = crate::auth::verification_token::generate_migration_token(&did, user_email);
let token =
crate::auth::verification_token::generate_migration_token(&did_for_commit, user_email);
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
if let Err(e) = crate::comms::comms_repo::enqueue_migration_verification(
state.user_repo.as_ref(),
@@ -809,7 +810,7 @@ pub async fn create_account(
}
};
let session_data = tranquil_db_traits::SessionTokenCreate {
did: unsafe { Did::new_unchecked(&did) },
did: did_for_commit.clone(),
access_jti: access_meta.jti.clone(),
refresh_jti: refresh_meta.jti.clone(),
access_expires_at: access_meta.expires_at,
@@ -838,12 +839,12 @@ pub async fn create_account(
StatusCode::OK,
Json(CreateAccountOutput {
handle: handle.clone().into(),
did: unsafe { Did::new_unchecked(&did) },
did: did_for_commit,
did_doc,
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
verification_required: !is_migration,
verification_channel: verification_channel.to_string(),
verification_channel,
}),
)
.into_response()
+100 -52
View File
@@ -42,7 +42,7 @@ pub async fn resolve_handle(
if handle_str.is_empty() {
return ApiError::InvalidRequest("handle is required".into()).into_response();
}
let cache_key = format!("handle:{}", handle_str);
let cache_key = crate::cache_keys::handle_key(handle_str);
if let Some(did) = state.cache.get(&cache_key).await {
return DidResponse::response(did).into_response();
}
@@ -78,12 +78,29 @@ pub async fn resolve_handle(
}
}
pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, &'static str> {
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?;
#[derive(Debug)]
pub enum KeyError {
InvalidKeyLength,
MissingCoordinate,
}
impl std::fmt::Display for KeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidKeyLength => write!(f, "invalid key length"),
Self::MissingCoordinate => write!(f, "missing elliptic curve coordinate"),
}
}
}
impl std::error::Error for KeyError {}
pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, KeyError> {
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| KeyError::InvalidKeyLength)?;
let public_key = secret_key.public_key();
let encoded = public_key.to_encoded_point(false);
let x = encoded.x().ok_or("Missing x coordinate")?;
let y = encoded.y().ok_or("Missing y coordinate")?;
let x = encoded.x().ok_or(KeyError::MissingCoordinate)?;
let y = encoded.y().ok_or(KeyError::MissingCoordinate)?;
let x_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(x);
let y_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(y);
Ok(json!({
@@ -94,8 +111,8 @@ pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, &'static str> {
}))
}
pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, &'static str> {
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?;
pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, KeyError> {
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| KeyError::InvalidKeyLength)?;
let public_key = secret_key.public_key();
let compressed = public_key.to_encoded_point(true);
let compressed_bytes = compressed.as_bytes();
@@ -107,7 +124,7 @@ pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, &'static str
pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
let hostname = pds_hostname();
let hostname_without_port = pds_hostname_without_port();
let host_header = get_header_str(&headers, "host").unwrap_or(hostname);
let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname);
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
if host_without_port != hostname_without_port
&& host_without_port.ends_with(&format!(".{}", hostname_without_port))
@@ -127,7 +144,7 @@ pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -
"id": did,
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"type": crate::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": format!("https://{}", hostname)
}]
}))
@@ -197,7 +214,7 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s
})).collect::<Vec<_>>(),
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"type": crate::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": service_endpoint
}]
}))
@@ -250,7 +267,7 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s
}],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"type": crate::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": service_endpoint
}]
}))
@@ -332,7 +349,7 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
})).collect::<Vec<_>>(),
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"type": crate::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": service_endpoint
}]
}))
@@ -385,19 +402,43 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
}],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"type": crate::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": service_endpoint
}]
}))
.into_response()
}
#[derive(Debug, thiserror::Error)]
pub enum DidWebVerifyError {
#[error("Invalid did:web format")]
InvalidFormat,
#[error("Invalid DID path for this PDS. Expected {0}")]
InvalidPath(String),
#[error(
"External did:web requires a pre-reserved signing key. Call com.atproto.server.reserveSigningKey first, configure your DID document with the returned key, then provide the signingKey in createAccount."
)]
MissingSigningKey,
#[error("Failed to fetch DID doc: {0}")]
FetchFailed(String),
#[error("Invalid DID document: {0}")]
InvalidDocument(String),
#[error("DID document does not list this PDS ({0}) as AtprotoPersonalDataServer")]
PdsNotListed(String),
#[error(
"DID document verification key does not match reserved signing key. Expected publicKeyMultibase: {0}"
)]
KeyMismatch(String),
#[error("Invalid signing key format")]
InvalidSigningKey,
}
pub async fn verify_did_web(
did: &str,
hostname: &str,
handle: &str,
expected_signing_key: Option<&str>,
) -> Result<(), String> {
) -> Result<(), DidWebVerifyError> {
let hostname_for_handles = hostname.split(':').next().unwrap_or(hostname);
let subdomain_host = format!("{}.{}", handle, hostname_for_handles);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
@@ -413,21 +454,16 @@ pub async fn verify_did_web(
if did.starts_with(&expected_prefix) {
let suffix = &did[expected_prefix.len()..];
let expected_suffix = format!(":u:{}", handle);
if suffix == expected_suffix {
return Ok(());
return if suffix == expected_suffix {
Ok(())
} else {
return Err(format!(
"Invalid DID path for this PDS. Expected {}",
expected_suffix
));
}
Err(DidWebVerifyError::InvalidPath(expected_suffix))
};
}
let expected_signing_key = expected_signing_key.ok_or_else(|| {
"External did:web requires a pre-reserved signing key. Call com.atproto.server.reserveSigningKey first, configure your DID document with the returned key, then provide the signingKey in createAccount.".to_string()
})?;
let expected_signing_key = expected_signing_key.ok_or(DidWebVerifyError::MissingSigningKey)?;
let parts: Vec<&str> = did.split(':').collect();
if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
return Err("Invalid did:web format".into());
return Err(DidWebVerifyError::InvalidFormat);
}
let domain_segment = parts[2];
let domain = domain_segment.replace("%3A", ":");
@@ -447,43 +483,46 @@ pub async fn verify_did_web(
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch DID doc: {}", e))?;
.map_err(|e| DidWebVerifyError::FetchFailed(e.to_string()))?;
if !resp.status().is_success() {
return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status()));
return Err(DidWebVerifyError::FetchFailed(format!(
"HTTP {}",
resp.status()
)));
}
let doc: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse DID doc: {}", e))?;
.map_err(|e| DidWebVerifyError::InvalidDocument(e.to_string()))?;
let services = doc["service"]
.as_array()
.ok_or("No services found in DID doc")?;
.ok_or(DidWebVerifyError::InvalidDocument(
"No services found".to_string(),
))?;
let pds_endpoint = format!("https://{}", hostname);
let has_valid_service = services
.iter()
.any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint);
let has_valid_service = services.iter().any(|s| {
s["type"] == crate::plc::ServiceType::Pds.as_str() && s["serviceEndpoint"] == pds_endpoint
});
if !has_valid_service {
return Err(format!(
"DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
pds_endpoint
));
return Err(DidWebVerifyError::PdsNotListed(pds_endpoint));
}
let verification_methods = doc["verificationMethod"]
.as_array()
.ok_or("No verificationMethod found in DID doc")?;
let verification_methods =
doc["verificationMethod"]
.as_array()
.ok_or(DidWebVerifyError::InvalidDocument(
"No verificationMethod found".to_string(),
))?;
let expected_multibase = expected_signing_key
.strip_prefix("did:key:")
.ok_or("Invalid signing key format")?;
.ok_or(DidWebVerifyError::InvalidSigningKey)?;
let has_matching_key = verification_methods.iter().any(|vm| {
vm["publicKeyMultibase"]
.as_str()
.map(|pk| pk == expected_multibase)
.unwrap_or(false)
.is_some_and(|pk| pk == expected_multibase)
});
if !has_matching_key {
return Err(format!(
"DID document verification key does not match reserved signing key. Expected publicKeyMultibase: {}",
expected_multibase
return Err(DidWebVerifyError::KeyMismatch(
expected_multibase.to_string(),
));
}
Ok(())
@@ -559,7 +598,7 @@ pub async fn get_recommended_did_credentials(
verification_methods: VerificationMethods { atproto: did_key },
services: Services {
atproto_pds: AtprotoPds {
service_type: "AtprotoPersonalDataServer".to_string(),
service_type: crate::plc::ServiceType::Pds.as_str().to_string(),
endpoint: pds_endpoint,
},
},
@@ -579,7 +618,7 @@ pub async fn update_handle(
Json(input): Json<UpdateHandleInput>,
) -> Result<Response, ApiError> {
if let Err(e) = crate::auth::scope_check::check_identity_scope(
auth.is_oauth(),
&auth.auth_source,
auth.scope.as_deref(),
crate::oauth::scopes::IdentityAttr::Handle,
) {
@@ -652,7 +691,10 @@ pub async fn update_handle(
format!("{}.{}", new_handle, hostname_for_handles)
};
if full_handle == current_handle {
let handle_typed = unsafe { Handle::new_unchecked(&full_handle) };
let handle_typed: Handle = match full_handle.parse() {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
};
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed))
.await
@@ -675,7 +717,10 @@ pub async fn update_handle(
full_handle
} else {
if new_handle == current_handle {
let handle_typed = unsafe { Handle::new_unchecked(&new_handle) };
let handle_typed: Handle = match new_handle.parse() {
Ok(h) => h,
Err(_) => return Err(ApiError::InvalidHandle(None)),
};
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed))
.await
@@ -728,10 +773,13 @@ pub async fn update_handle(
if !current_handle.is_empty() {
let _ = state
.cache
.delete(&format!("handle:{}", current_handle))
.delete(&crate::cache_keys::handle_key(&current_handle))
.await;
}
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
let _ = state
.cache
.delete(&crate::cache_keys::handle_key(&handle))
.await;
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed)).await
{
@@ -768,7 +816,7 @@ pub async fn update_plc_handle(
}
pub async fn well_known_atproto_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
let host = match crate::util::get_header_str(&headers, "host") {
let host = match crate::util::get_header_str(&headers, http::header::HOST) {
Some(h) => h,
None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(),
};
+2 -10
View File
@@ -1,4 +1,3 @@
use crate::api::error::ApiError;
use crate::rate_limit::{HandleVerificationLimit, RateLimited};
use crate::types::{Did, Handle};
use axum::{
@@ -9,7 +8,7 @@ use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct VerifyHandleOwnershipInput {
pub handle: String,
pub handle: Handle,
pub did: Did,
}
@@ -27,14 +26,7 @@ pub async fn verify_handle_ownership(
_rate_limit: RateLimited<HandleVerificationLimit>,
Json(input): Json<VerifyHandleOwnershipInput>,
) -> Response {
let handle: Handle = match input.handle.parse() {
Ok(h) => h,
Err(_) => {
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
}
};
let handle_str = handle.as_str();
let handle_str = input.handle.as_str();
let did_str = input.did.as_str();
let dns_mismatch = match crate::handle::resolve_handle_dns(handle_str).await {
@@ -19,7 +19,7 @@ pub async fn request_plc_operation_signature(
auth: Auth<Permissive>,
) -> Result<Response, ApiError> {
if let Err(e) = crate::auth::scope_check::check_identity_scope(
auth.is_oauth(),
&auth.auth_source,
auth.scope.as_deref(),
crate::oauth::scopes::IdentityAttr::Wildcard,
) {
@@ -2,7 +2,7 @@ use crate::api::ApiError;
use crate::api::error::DbResultExt;
use crate::auth::{Auth, Permissive};
use crate::circuit_breaker::with_circuit_breaker;
use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation};
use crate::plc::{PlcClient, PlcError, PlcService, ServiceType, create_update_op, sign_operation};
use crate::state::AppState;
use axum::{
Json,
@@ -30,7 +30,7 @@ pub struct SignPlcOperationInput {
#[derive(Debug, Deserialize, Clone)]
pub struct ServiceInput {
#[serde(rename = "type")]
pub service_type: String,
pub service_type: ServiceType,
pub endpoint: String,
}
@@ -45,7 +45,7 @@ pub async fn sign_plc_operation(
Json(input): Json<SignPlcOperationInput>,
) -> Result<Response, ApiError> {
if let Err(e) = crate::auth::scope_check::check_identity_scope(
auth.is_oauth(),
&auth.auth_source,
auth.scope.as_deref(),
crate::oauth::scopes::IdentityAttr::Wildcard,
) {
@@ -26,7 +26,7 @@ pub async fn submit_plc_operation(
Json(input): Json<SubmitPlcOperationInput>,
) -> Result<Response, ApiError> {
if let Err(e) = crate::auth::scope_check::check_identity_scope(
auth.is_oauth(),
&auth.auth_source,
auth.scope.as_deref(),
crate::oauth::scopes::IdentityAttr::Wildcard,
) {
@@ -87,7 +87,7 @@ pub async fn submit_plc_operation(
{
let service_type = pds.get("type").and_then(|v| v.as_str());
let endpoint = pds.get("endpoint").and_then(|v| v.as_str());
if service_type != Some("AtprotoPersonalDataServer") {
if service_type != Some(crate::plc::ServiceType::Pds.as_str()) {
return Err(ApiError::InvalidRequest(
"Incorrect type on atproto_pds service".into(),
));
@@ -143,9 +143,18 @@ pub async fn submit_plc_operation(
warn!("Failed to sequence identity event: {:?}", e);
}
}
let _ = state.cache.delete(&format!("handle:{}", user.handle)).await;
let _ = state.cache.delete(&format!("plc:doc:{}", did)).await;
let _ = state.cache.delete(&format!("plc:data:{}", did)).await;
let _ = state
.cache
.delete(&crate::cache_keys::handle_key(&user.handle))
.await;
let _ = state
.cache
.delete(&crate::cache_keys::plc_doc_key(did))
.await;
let _ = state
.cache
.delete(&crate::cache_keys::plc_data_key(did))
.await;
if state.did_resolver.refresh_did(did).await.is_none() {
warn!(did = %did, "Failed to refresh DID cache after PLC update");
}
+1 -1
View File
@@ -19,7 +19,7 @@ pub mod validation;
pub mod verification;
pub use error::ApiError;
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit};
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_limit};
pub use responses::{
DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse,
StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse,
+48 -26
View File
@@ -12,10 +12,42 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tracing::{error, info};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReportReasonType {
#[serde(rename = "com.atproto.moderation.defs#reasonSpam")]
Spam,
#[serde(rename = "com.atproto.moderation.defs#reasonViolation")]
Violation,
#[serde(rename = "com.atproto.moderation.defs#reasonMisleading")]
Misleading,
#[serde(rename = "com.atproto.moderation.defs#reasonSexual")]
Sexual,
#[serde(rename = "com.atproto.moderation.defs#reasonRude")]
Rude,
#[serde(rename = "com.atproto.moderation.defs#reasonOther")]
Other,
#[serde(rename = "com.atproto.moderation.defs#reasonAppeal")]
Appeal,
}
impl ReportReasonType {
pub fn as_str(self) -> &'static str {
match self {
Self::Spam => "com.atproto.moderation.defs#reasonSpam",
Self::Violation => "com.atproto.moderation.defs#reasonViolation",
Self::Misleading => "com.atproto.moderation.defs#reasonMisleading",
Self::Sexual => "com.atproto.moderation.defs#reasonSexual",
Self::Rude => "com.atproto.moderation.defs#reasonRude",
Self::Other => "com.atproto.moderation.defs#reasonOther",
Self::Appeal => "com.atproto.moderation.defs#reasonAppeal",
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateReportInput {
pub reason_type: String,
pub reason_type: ReportReasonType,
pub reason: Option<String>,
pub subject: Value,
}
@@ -24,20 +56,25 @@ pub struct CreateReportInput {
#[serde(rename_all = "camelCase")]
pub struct CreateReportOutput {
pub id: i64,
pub reason_type: String,
pub reason_type: ReportReasonType,
pub reason: Option<String>,
pub subject: Value,
pub reported_by: String,
pub created_at: String,
}
fn get_report_service_config() -> Option<(String, String)> {
struct ReportServiceConfig {
url: String,
did: String,
}
fn get_report_service_config() -> Option<ReportServiceConfig> {
let url = std::env::var("REPORT_SERVICE_URL").ok()?;
let did = std::env::var("REPORT_SERVICE_DID").ok()?;
if url.is_empty() || did.is_empty() {
return None;
}
Some((url, did))
Some(ReportServiceConfig { url, did })
}
pub async fn create_report(
@@ -47,8 +84,8 @@ pub async fn create_report(
) -> Response {
let did = &auth.did;
if let Some((service_url, service_did)) = get_report_service_config() {
return proxy_to_report_service(&state, &auth, &service_url, &service_did, &input).await;
if let Some(config) = get_report_service_config() {
return proxy_to_report_service(&state, &auth, &config.url, &config.did, &input).await;
}
create_report_locally(&state, did, auth.status.is_takendown(), input).await
@@ -177,36 +214,21 @@ async fn create_report_locally(
is_takendown: bool,
input: CreateReportInput,
) -> Response {
const REASON_APPEAL: &str = "com.atproto.moderation.defs#reasonAppeal";
if is_takendown && input.reason_type != REASON_APPEAL {
if is_takendown && input.reason_type != ReportReasonType::Appeal {
return ApiError::InvalidRequest("Report not accepted from takendown account".into())
.into_response();
}
let valid_reason_types = [
"com.atproto.moderation.defs#reasonSpam",
"com.atproto.moderation.defs#reasonViolation",
"com.atproto.moderation.defs#reasonMisleading",
"com.atproto.moderation.defs#reasonSexual",
"com.atproto.moderation.defs#reasonRude",
"com.atproto.moderation.defs#reasonOther",
REASON_APPEAL,
];
if !valid_reason_types.contains(&input.reason_type.as_str()) {
return ApiError::InvalidRequest("Invalid reasonType".into()).into_response();
}
let created_at = chrono::Utc::now();
let report_id = (uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF) as i64;
let report_id = i64::try_from(uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF)
.expect("masked to 63 bits, always fits i64");
let subject_json = json!(input.subject);
if let Err(e) = state
.infra_repo
.insert_report(
report_id,
&input.reason_type,
input.reason_type.as_str(),
input.reason.as_deref(),
subject_json,
did,
@@ -221,7 +243,7 @@ async fn create_report_locally(
info!(
report_id = %report_id,
reported_by = %did,
reason_type = %input.reason_type,
reason_type = input.reason_type.as_str(),
"Report created locally (no report service configured)"
);
+102 -105
View File
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::info;
use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType};
use tranquil_types::Did;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
@@ -130,91 +131,95 @@ pub struct UpdateNotificationPrefsInput {
pub struct UpdateNotificationPrefsResponse {
pub success: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub verification_required: Vec<String>,
pub verification_required: Vec<CommsChannel>,
}
pub async fn request_channel_verification(
state: &AppState,
user_id: uuid::Uuid,
did: &str,
channel: &str,
did: &Did,
channel: CommsChannel,
identifier: &str,
handle: Option<&str>,
) -> Result<String, String> {
) -> Result<String, ApiError> {
let token =
crate::auth::verification_token::generate_channel_update_token(did, channel, identifier);
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
if channel == "email" {
let hostname = pds_hostname();
let handle_str = handle.unwrap_or("user");
crate::comms::comms_repo::enqueue_email_update(
state.infra_repo.as_ref(),
user_id,
identifier,
handle_str,
&formatted_token,
hostname,
)
.await
.map_err(|e| format!("Failed to enqueue email notification: {}", e))?;
} else {
let comms_channel = match channel {
"discord" => tranquil_db_traits::CommsChannel::Discord,
"telegram" => tranquil_db_traits::CommsChannel::Telegram,
"signal" => tranquil_db_traits::CommsChannel::Signal,
_ => return Err("Invalid channel".to_string()),
};
let hostname = pds_hostname();
let encoded_token = urlencoding::encode(&formatted_token);
let encoded_identifier = urlencoding::encode(identifier);
let verify_link = format!(
"https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_identifier
);
let prefs = state
.user_repo
.get_comms_prefs(user_id)
.await
.ok()
.flatten();
let locale = prefs
.as_ref()
.and_then(|p| p.preferred_locale.as_deref())
.unwrap_or("en");
let strings = crate::comms::get_strings(locale);
let body = crate::comms::format_message(
strings.channel_verification_body,
&[("code", &formatted_token), ("verify_link", &verify_link)],
);
let subject = crate::comms::format_message(
strings.channel_verification_subject,
&[("hostname", hostname)],
);
let recipient = match comms_channel {
tranquil_db_traits::CommsChannel::Telegram => state
.user_repo
.get_telegram_chat_id(user_id)
.await
.ok()
.flatten()
.map(|id| id.to_string())
.unwrap_or_else(|| identifier.to_string()),
_ => identifier.to_string(),
};
state
.infra_repo
.enqueue_comms(
Some(user_id),
comms_channel,
tranquil_db_traits::CommsType::ChannelVerification,
&recipient,
Some(&subject),
&body,
Some(json!({"code": formatted_token})),
match channel {
CommsChannel::Email => {
let hostname = pds_hostname();
let handle_str = handle.unwrap_or("user");
crate::comms::comms_repo::enqueue_email_update(
state.infra_repo.as_ref(),
user_id,
identifier,
handle_str,
&formatted_token,
hostname,
)
.await
.map_err(|e| format!("Failed to enqueue notification: {}", e))?;
.map_err(|e| {
ApiError::InternalError(Some(format!(
"Failed to enqueue email notification: {}",
e
)))
})?;
}
_ => {
let hostname = pds_hostname();
let encoded_token = urlencoding::encode(&formatted_token);
let encoded_identifier = urlencoding::encode(identifier);
let verify_link = format!(
"https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_identifier
);
let prefs = state
.user_repo
.get_comms_prefs(user_id)
.await
.ok()
.flatten();
let locale = prefs
.as_ref()
.and_then(|p| p.preferred_locale.as_deref())
.unwrap_or("en");
let strings = crate::comms::get_strings(locale);
let body = crate::comms::format_message(
strings.channel_verification_body,
&[("code", &formatted_token), ("verify_link", &verify_link)],
);
let subject = crate::comms::format_message(
strings.channel_verification_subject,
&[("hostname", hostname)],
);
let recipient = match channel {
CommsChannel::Telegram => state
.user_repo
.get_telegram_chat_id(user_id)
.await
.ok()
.flatten()
.map(|id| id.to_string())
.unwrap_or_else(|| identifier.to_string()),
_ => identifier.to_string(),
};
state
.infra_repo
.enqueue_comms(
Some(user_id),
channel,
tranquil_db_traits::CommsType::ChannelVerification,
&recipient,
Some(&subject),
&body,
Some(json!({"code": formatted_token})),
)
.await
.map_err(|e| {
ApiError::InternalError(Some(format!("Failed to enqueue notification: {}", e)))
})?;
}
}
Ok(token)
@@ -246,38 +251,25 @@ pub async fn update_notification_prefs(
let effective_channel = input
.preferred_channel
.as_deref()
.map(|ch| match ch {
"email" => Ok(CommsChannel::Email),
"discord" => Ok(CommsChannel::Discord),
"telegram" => Ok(CommsChannel::Telegram),
"signal" => Ok(CommsChannel::Signal),
_ => Err(ApiError::InvalidRequest(
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
)),
.map(|ch| {
ch.parse::<CommsChannel>().map_err(|_| {
ApiError::InvalidRequest(
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
)
})
})
.transpose()?
.unwrap_or(current_prefs.preferred_channel);
let mut verification_required: Vec<String> = Vec::new();
let mut verification_required: Vec<CommsChannel> = Vec::new();
if let Some(ref channel_str) = input.preferred_channel {
let channel = match channel_str.as_str() {
"email" => CommsChannel::Email,
"discord" => CommsChannel::Discord,
"telegram" => CommsChannel::Telegram,
"signal" => CommsChannel::Signal,
_ => {
return Err(ApiError::InvalidRequest(
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
));
}
};
if input.preferred_channel.is_some() {
state
.user_repo
.update_preferred_comms_channel(&auth.did, channel)
.update_preferred_comms_channel(&auth.did, effective_channel)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
info!(did = %auth.did, channel = ?channel, "Updated preferred notification channel");
info!(did = %auth.did, channel = ?effective_channel, "Updated preferred notification channel");
}
if let Some(ref new_email) = input.email {
@@ -295,13 +287,12 @@ pub async fn update_notification_prefs(
&state,
user_id,
&auth.did,
"email",
CommsChannel::Email,
&email_clean,
Some(&handle),
)
.await
.map_err(|e| ApiError::InternalError(Some(e)))?;
verification_required.push("email".to_string());
.await?;
verification_required.push(CommsChannel::Email);
info!(did = %auth.did, "Requested email verification");
}
}
@@ -331,7 +322,7 @@ pub async fn update_notification_prefs(
.set_unverified_discord(user_id, &discord_clean)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
verification_required.push("discord".to_string());
verification_required.push(CommsChannel::Discord);
info!(did = %auth.did, discord_username = %discord_clean, "Stored unverified Discord username");
}
}
@@ -361,7 +352,7 @@ pub async fn update_notification_prefs(
.set_unverified_telegram(user_id, telegram_clean)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
verification_required.push("telegram".to_string());
verification_required.push(CommsChannel::Telegram);
info!(did = %auth.did, telegram_username = %telegram_clean, "Stored unverified Telegram username");
}
}
@@ -391,10 +382,16 @@ pub async fn update_notification_prefs(
.set_unverified_signal(user_id, &signal_clean)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
request_channel_verification(&state, user_id, &auth.did, "signal", &signal_clean, None)
.await
.map_err(|e| ApiError::InternalError(Some(e)))?;
verification_required.push("signal".to_string());
request_channel_verification(
&state,
user_id,
&auth.did,
CommsChannel::Signal,
&signal_clean,
None,
)
.await?;
verification_required.push(CommsChannel::Signal);
info!(did = %auth.did, signal_username = %signal_clean, "Stored unverified Signal username");
}
}
+106 -97
View File
@@ -1,4 +1,6 @@
use std::collections::HashSet;
use std::convert::Infallible;
use std::sync::LazyLock;
use crate::api::error::ApiError;
use crate::api::proxy_client::proxy_client;
@@ -15,92 +17,96 @@ use futures_util::future::Either;
use tower::{Service, util::BoxCloneSyncService};
use tracing::{error, info, warn};
const PROTECTED_METHODS: &[&str] = &[
"app.bsky.actor.getPreferences",
"app.bsky.actor.putPreferences",
"com.atproto.admin.deleteAccount",
"com.atproto.admin.disableAccountInvites",
"com.atproto.admin.disableInviteCodes",
"com.atproto.admin.enableAccountInvites",
"com.atproto.admin.getAccountInfo",
"com.atproto.admin.getAccountInfos",
"com.atproto.admin.getInviteCodes",
"com.atproto.admin.getSubjectStatus",
"com.atproto.admin.searchAccounts",
"com.atproto.admin.sendEmail",
"com.atproto.admin.updateAccountEmail",
"com.atproto.admin.updateAccountHandle",
"com.atproto.admin.updateAccountPassword",
"com.atproto.admin.updateSubjectStatus",
"com.atproto.identity.getRecommendedDidCredentials",
"com.atproto.identity.requestPlcOperationSignature",
"com.atproto.identity.signPlcOperation",
"com.atproto.identity.submitPlcOperation",
"com.atproto.identity.updateHandle",
"com.atproto.repo.applyWrites",
"com.atproto.repo.createRecord",
"com.atproto.repo.deleteRecord",
"com.atproto.repo.importRepo",
"com.atproto.repo.putRecord",
"com.atproto.repo.uploadBlob",
"com.atproto.server.activateAccount",
"com.atproto.server.checkAccountStatus",
"com.atproto.server.confirmEmail",
"com.atproto.server.confirmSignup",
"com.atproto.server.createAccount",
"com.atproto.server.createAppPassword",
"com.atproto.server.createInviteCode",
"com.atproto.server.createInviteCodes",
"com.atproto.server.createSession",
"com.atproto.server.createTotpSecret",
"com.atproto.server.deactivateAccount",
"com.atproto.server.deleteAccount",
"com.atproto.server.deletePasskey",
"com.atproto.server.deleteSession",
"com.atproto.server.describeServer",
"com.atproto.server.disableTotp",
"com.atproto.server.enableTotp",
"com.atproto.server.finishPasskeyRegistration",
"com.atproto.server.getAccountInviteCodes",
"com.atproto.server.getServiceAuth",
"com.atproto.server.getSession",
"com.atproto.server.getTotpStatus",
"com.atproto.server.listAppPasswords",
"com.atproto.server.listPasskeys",
"com.atproto.server.refreshSession",
"com.atproto.server.regenerateBackupCodes",
"com.atproto.server.requestAccountDelete",
"com.atproto.server.requestEmailConfirmation",
"com.atproto.server.requestEmailUpdate",
"com.atproto.server.requestPasswordReset",
"com.atproto.server.resendMigrationVerification",
"com.atproto.server.resendVerification",
"com.atproto.server.reserveSigningKey",
"com.atproto.server.resetPassword",
"com.atproto.server.revokeAppPassword",
"com.atproto.server.startPasskeyRegistration",
"com.atproto.server.updateEmail",
"com.atproto.server.updatePasskey",
"com.atproto.server.verifyMigrationEmail",
"com.atproto.sync.getBlob",
"com.atproto.sync.getBlocks",
"com.atproto.sync.getCheckout",
"com.atproto.sync.getHead",
"com.atproto.sync.getLatestCommit",
"com.atproto.sync.getRecord",
"com.atproto.sync.getRepo",
"com.atproto.sync.getRepoStatus",
"com.atproto.sync.listBlobs",
"com.atproto.sync.listRepos",
"com.atproto.sync.notifyOfUpdate",
"com.atproto.sync.requestCrawl",
"com.atproto.sync.subscribeRepos",
"com.atproto.temp.checkSignupQueue",
"com.atproto.temp.dereferenceScope",
];
static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
[
"app.bsky.actor.getPreferences",
"app.bsky.actor.putPreferences",
"com.atproto.admin.deleteAccount",
"com.atproto.admin.disableAccountInvites",
"com.atproto.admin.disableInviteCodes",
"com.atproto.admin.enableAccountInvites",
"com.atproto.admin.getAccountInfo",
"com.atproto.admin.getAccountInfos",
"com.atproto.admin.getInviteCodes",
"com.atproto.admin.getSubjectStatus",
"com.atproto.admin.searchAccounts",
"com.atproto.admin.sendEmail",
"com.atproto.admin.updateAccountEmail",
"com.atproto.admin.updateAccountHandle",
"com.atproto.admin.updateAccountPassword",
"com.atproto.admin.updateSubjectStatus",
"com.atproto.identity.getRecommendedDidCredentials",
"com.atproto.identity.requestPlcOperationSignature",
"com.atproto.identity.signPlcOperation",
"com.atproto.identity.submitPlcOperation",
"com.atproto.identity.updateHandle",
"com.atproto.repo.applyWrites",
"com.atproto.repo.createRecord",
"com.atproto.repo.deleteRecord",
"com.atproto.repo.importRepo",
"com.atproto.repo.putRecord",
"com.atproto.repo.uploadBlob",
"com.atproto.server.activateAccount",
"com.atproto.server.checkAccountStatus",
"com.atproto.server.confirmEmail",
"com.atproto.server.confirmSignup",
"com.atproto.server.createAccount",
"com.atproto.server.createAppPassword",
"com.atproto.server.createInviteCode",
"com.atproto.server.createInviteCodes",
"com.atproto.server.createSession",
"com.atproto.server.createTotpSecret",
"com.atproto.server.deactivateAccount",
"com.atproto.server.deleteAccount",
"com.atproto.server.deletePasskey",
"com.atproto.server.deleteSession",
"com.atproto.server.describeServer",
"com.atproto.server.disableTotp",
"com.atproto.server.enableTotp",
"com.atproto.server.finishPasskeyRegistration",
"com.atproto.server.getAccountInviteCodes",
"com.atproto.server.getServiceAuth",
"com.atproto.server.getSession",
"com.atproto.server.getTotpStatus",
"com.atproto.server.listAppPasswords",
"com.atproto.server.listPasskeys",
"com.atproto.server.refreshSession",
"com.atproto.server.regenerateBackupCodes",
"com.atproto.server.requestAccountDelete",
"com.atproto.server.requestEmailConfirmation",
"com.atproto.server.requestEmailUpdate",
"com.atproto.server.requestPasswordReset",
"com.atproto.server.resendMigrationVerification",
"com.atproto.server.resendVerification",
"com.atproto.server.reserveSigningKey",
"com.atproto.server.resetPassword",
"com.atproto.server.revokeAppPassword",
"com.atproto.server.startPasskeyRegistration",
"com.atproto.server.updateEmail",
"com.atproto.server.updatePasskey",
"com.atproto.server.verifyMigrationEmail",
"com.atproto.sync.getBlob",
"com.atproto.sync.getBlocks",
"com.atproto.sync.getCheckout",
"com.atproto.sync.getHead",
"com.atproto.sync.getLatestCommit",
"com.atproto.sync.getRecord",
"com.atproto.sync.getRepo",
"com.atproto.sync.getRepoStatus",
"com.atproto.sync.listBlobs",
"com.atproto.sync.listRepos",
"com.atproto.sync.notifyOfUpdate",
"com.atproto.sync.requestCrawl",
"com.atproto.sync.subscribeRepos",
"com.atproto.temp.checkSignupQueue",
"com.atproto.temp.dereferenceScope",
]
.into_iter()
.collect()
});
fn is_protected_method(method: &str) -> bool {
PROTECTED_METHODS.contains(&method)
PROTECTED_METHODS.contains(method)
}
pub struct XrpcProxyLayer {
@@ -192,7 +198,9 @@ async fn proxy_handler(
.into_response();
}
let Some(proxy_header) = get_header_str(&headers, "atproto-proxy").map(String::from) else {
let Some(proxy_header) =
get_header_str(&headers, crate::util::HEADER_ATPROTO_PROXY).map(String::from)
else {
return ApiError::InvalidRequest("Missing required atproto-proxy header".into())
.into_response();
};
@@ -212,30 +220,29 @@ async fn proxy_handler(
let client = proxy_client();
let mut request_builder = client.request(method_verb.clone(), &target_url);
let mut auth_header_val = headers.get("Authorization").cloned();
let mut auth_header_val = headers.get(http::header::AUTHORIZATION).cloned();
if let Some(extracted) = crate::auth::extract_auth_token_from_header(
crate::util::get_header_str(&headers, "Authorization"),
crate::util::get_header_str(&headers, http::header::AUTHORIZATION),
) {
let token = extracted.token;
let dpop_proof = crate::util::get_header_str(&headers, "DPoP");
let dpop_proof = crate::util::get_header_str(&headers, crate::util::HEADER_DPOP);
let http_uri = crate::util::build_full_url(&format!("/xrpc{}", uri));
match crate::auth::validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&token,
extracted.is_dpop,
extracted.scheme,
dpop_proof,
method_verb.as_str(),
&http_uri,
false,
false,
crate::auth::AccountRequirement::Active,
)
.await
{
Ok(auth_user) => {
if let Err(e) = crate::auth::scope_check::check_rpc_scope(
auth_user.is_oauth(),
&auth_user.auth_source,
auth_user.scope.as_deref(),
&resolved.did,
method,
@@ -298,7 +305,9 @@ async fn proxy_handler(
info!(error = ?e, "Proxy token validation failed, returning error to client");
let mut response = ApiError::from(e).into_response();
if let Ok(nonce_val) = crate::oauth::verify::generate_dpop_nonce().parse() {
response.headers_mut().insert("DPoP-Nonce", nonce_val);
response
.headers_mut()
.insert(crate::util::HEADER_DPOP_NONCE, nonce_val);
}
return response;
}
@@ -306,13 +315,13 @@ async fn proxy_handler(
}
if let Some(val) = auth_header_val {
request_builder = request_builder.header("Authorization", val);
request_builder = request_builder.header(http::header::AUTHORIZATION, val);
}
request_builder = crate::api::proxy_client::HEADERS_TO_FORWARD
.iter()
.filter_map(|name| headers.get(*name).map(|val| (*name, val)))
.filter_map(|name| headers.get(name).map(|val| (name, val)))
.fold(request_builder, |builder, (name, val)| {
builder.header(name, val)
builder.header(name.as_str(), val)
});
if !body.is_empty() {
request_builder = request_builder.body(body);
@@ -333,7 +342,7 @@ async fn proxy_handler(
let mut response_builder = Response::builder().status(status);
response_builder = crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD
.iter()
.filter_map(|name| headers.get(*name).map(|val| (*name, val)))
.filter_map(|name| headers.get(name).map(|val| (name, val)))
.fold(response_builder, |builder, (name, val)| {
builder.header(name, val)
});
+44 -56
View File
@@ -1,8 +1,10 @@
use axum::http::HeaderName;
use reqwest::{Client, ClientBuilder, Url};
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::sync::OnceLock;
use std::sync::{LazyLock, OnceLock};
use std::time::Duration;
use tracing::warn;
use tranquil_types::{Did, Nsid, Rkey};
pub const DEFAULT_HEADERS_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_BODY_TIMEOUT: Duration = Duration::from_secs(30);
@@ -146,20 +148,24 @@ impl std::fmt::Display for SsrfError {
impl std::error::Error for SsrfError {}
pub const HEADERS_TO_FORWARD: &[&str] = &[
"accept-language",
"atproto-accept-labelers",
"x-bsky-topics",
"content-type",
];
pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[
"atproto-repo-rev",
"atproto-content-labelers",
"retry-after",
"content-type",
"cache-control",
"etag",
];
pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| {
[
HeaderName::from_static("accept-language"),
crate::util::HEADER_ATPROTO_ACCEPT_LABELERS,
crate::util::HEADER_X_BSKY_TOPICS,
http::header::CONTENT_TYPE,
]
});
pub static RESPONSE_HEADERS_TO_FORWARD: LazyLock<[HeaderName; 6]> = LazyLock::new(|| {
[
crate::util::HEADER_ATPROTO_REPO_REV,
crate::util::HEADER_ATPROTO_CONTENT_LABELERS,
HeaderName::from_static("retry-after"),
http::header::CONTENT_TYPE,
http::header::CACHE_CONTROL,
http::header::ETAG,
]
});
pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
if !uri.starts_with("at://") {
@@ -170,28 +176,29 @@ pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
if parts.is_empty() {
return Err("URI missing DID");
}
let did = parts[0];
if !did.starts_with("did:") {
return Err("Invalid DID in URI");
}
if parts.len() > 1 {
let collection = parts[1];
if collection.is_empty() || !collection.contains('.') {
return Err("Invalid collection NSID");
}
}
let did: Did = parts[0].parse().map_err(|_| "Invalid DID in URI")?;
let collection = parts
.get(1)
.map(|s| s.parse::<Nsid>())
.transpose()
.map_err(|_| "Invalid collection NSID")?;
let rkey = parts
.get(2)
.map(|s| s.parse::<Rkey>())
.transpose()
.map_err(|_| "Invalid rkey")?;
Ok(AtUriParts {
did: did.to_string(),
collection: parts.get(1).map(|s| s.to_string()),
rkey: parts.get(2).map(|s| s.to_string()),
did,
collection,
rkey,
})
}
#[derive(Debug, Clone)]
pub struct AtUriParts {
pub did: String,
pub collection: Option<String>,
pub rkey: Option<String>,
pub did: Did,
pub collection: Option<Nsid>,
pub rkey: Option<Rkey>,
}
pub fn validate_limit(limit: Option<u32>, default: u32, max: u32) -> u32 {
@@ -203,21 +210,6 @@ pub fn validate_limit(limit: Option<u32>, default: u32, max: u32) -> u32 {
}
}
pub fn validate_did(did: &str) -> Result<(), &'static str> {
if !did.starts_with("did:") {
return Err("Invalid DID format");
}
let parts: Vec<&str> = did.split(':').collect();
if parts.len() < 3 {
return Err("DID must have at least method and identifier");
}
let method = parts[1];
if method != "plc" && method != "web" {
return Err("Unsupported DID method");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -243,9 +235,12 @@ mod tests {
let result = validate_at_uri("at://did:plc:test/app.bsky.feed.post/abc123");
assert!(result.is_ok());
let parts = result.unwrap();
assert_eq!(parts.did, "did:plc:test");
assert_eq!(parts.collection, Some("app.bsky.feed.post".to_string()));
assert_eq!(parts.rkey, Some("abc123".to_string()));
assert_eq!(parts.did, "did:plc:test".parse::<Did>().unwrap());
assert_eq!(
parts.collection,
Some("app.bsky.feed.post".parse::<Nsid>().unwrap())
);
assert_eq!(parts.rkey, Some("abc123".parse::<Rkey>().unwrap()));
}
#[test]
fn test_validate_at_uri_invalid() {
@@ -259,11 +254,4 @@ mod tests {
assert_eq!(validate_limit(Some(200), 50, 100), 100);
assert_eq!(validate_limit(Some(75), 50, 100), 75);
}
#[test]
fn test_validate_did() {
assert!(validate_did("did:plc:abc123").is_ok());
assert!(validate_did("did:web:example.com").is_ok());
assert!(validate_did("notadid").is_err());
assert!(validate_did("did:unknown:test").is_err());
}
}
+14 -7
View File
@@ -56,8 +56,8 @@ pub async fn upload_blob(
if user.status.is_takendown() {
return Err(ApiError::AccountTakedown);
}
let mime_type_for_check =
get_header_str(&headers, "content-type").unwrap_or("application/octet-stream");
let mime_type_for_check = get_header_str(&headers, http::header::CONTENT_TYPE)
.unwrap_or("application/octet-stream");
let scope_proof = match user.verify_blob_upload(mime_type_for_check) {
Ok(proof) => proof,
Err(e) => return Ok(e.into_response()),
@@ -79,7 +79,7 @@ pub async fn upload_blob(
}
let client_mime_hint =
get_header_str(&headers, "content-type").unwrap_or("application/octet-stream");
get_header_str(&headers, http::header::CONTENT_TYPE).unwrap_or("application/octet-stream");
let user_id = state
.user_repo
@@ -89,7 +89,7 @@ pub async fn upload_blob(
.ok_or(ApiError::InternalError(None))?;
let temp_key = format!("temp/{}", uuid::Uuid::new_v4());
let max_size = get_max_blob_size() as u64;
let max_size = u64::try_from(get_max_blob_size()).unwrap_or(u64::MAX);
let body_stream = body.into_data_stream();
let mapped_stream =
@@ -148,7 +148,13 @@ pub async fn upload_blob(
match state
.blob_repo
.insert_blob(&cid_link, &mime_type, size as i64, user_id, &storage_key)
.insert_blob(
&cid_link,
&mime_type,
i64::try_from(size).unwrap_or(i64::MAX),
user_id,
&storage_key,
)
.await
{
Ok(_) => {}
@@ -248,10 +254,11 @@ pub async fn list_missing_blobs(
.await
.log_db_err("fetching missing blobs")?;
let has_more = missing.len() > limit as usize;
let limit_usize = usize::try_from(limit).unwrap_or(0);
let has_more = missing.len() > limit_usize;
let blobs: Vec<RecordBlob> = missing
.into_iter()
.take(limit as usize)
.take(limit_usize)
.map(|m| RecordBlob {
cid: m.blob_cid.to_string(),
record_uri: m.record_uri.to_string(),
+20 -13
View File
@@ -92,8 +92,12 @@ pub async fn import_repo(
"Root block not found in CAR file".into(),
));
};
let commit_did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
Ok(commit) => commit.did().to_string(),
let commit_did: Did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
Ok(commit) => commit
.did()
.as_str()
.parse()
.map_err(|_| ApiError::InvalidRequest("Commit contains invalid DID".into()))?,
Err(e) => {
return Err(ApiError::InvalidRequest(format!("Invalid commit: {}", e)));
}
@@ -104,9 +108,7 @@ pub async fn import_repo(
commit_did, did
)));
}
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let skip_verification = crate::util::parse_env_bool("SKIP_IMPORT_VERIFICATION");
let is_migration = user.deactivated_at.is_some();
if skip_verification {
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
@@ -221,10 +223,14 @@ pub async fn import_repo(
.flat_map(|record| {
let record_uri =
AtUri::from_parts(did.as_str(), &record.collection, &record.rkey);
record.blob_refs.iter().map(move |blob_ref| {
(record_uri.clone(), unsafe {
CidLink::new_unchecked(blob_ref.cid.clone())
})
record.blob_refs.iter().filter_map(move |blob_ref| {
match CidLink::new(&blob_ref.cid) {
Ok(cid_link) => Some((record_uri.clone(), cid_link)),
Err(_) => {
tracing::warn!(cid = %blob_ref.cid, "skipping unparseable blob CID reference during import");
None
}
}
})
})
.collect();
@@ -289,7 +295,7 @@ pub async fn import_repo(
error!("Failed to store new commit block: {:?}", e);
ApiError::InternalError(None)
})?;
let new_root_cid_link = unsafe { CidLink::new_unchecked(new_root_cid.to_string()) };
let new_root_cid_link = CidLink::from(&new_root_cid);
state
.repo_repo
.update_repo_root(user_id, &new_root_cid_link, &new_rev_str)
@@ -313,7 +319,8 @@ pub async fn import_repo(
"Created new commit for imported repo: cid={}, rev={}",
new_root_str, new_rev_str
);
if !is_migration && let Err(e) = sequence_import_event(&state, did, &new_root_str).await
if !is_migration
&& let Err(e) = sequence_import_event(&state, did, &new_root_cid_link).await
{
warn!("Failed to sequence import event: {:?}", e);
}
@@ -378,12 +385,12 @@ pub async fn import_repo(
async fn sequence_import_event(
state: &AppState,
did: &Did,
commit_cid: &str,
commit_cid: &CidLink,
) -> Result<(), tranquil_db::DbError> {
let data = tranquil_db::CommitEventData {
did: did.clone(),
event_type: tranquil_db::RepoEventType::Commit,
commit_cid: Some(unsafe { CidLink::new_unchecked(commit_cid) }),
commit_cid: Some(commit_cid.clone()),
prev_cid: None,
ops: Some(serde_json::json!([])),
blobs: Some(vec![]),
@@ -11,6 +11,7 @@ use crate::delegation::DelegationActionType;
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
use crate::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
use crate::validation::ValidationStatus;
use axum::{
Json,
extract::State,
@@ -23,7 +24,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;
use std::sync::Arc;
use tracing::{error, info};
use tracing::info;
const MAX_BATCH_WRITES: usize = 200;
@@ -87,7 +88,7 @@ async fn process_single_write(
results.push(WriteResult::CreateResult {
uri,
cid: record_cid.to_string(),
validation_status: validation_status.map(|s| s.to_string()),
validation_status,
});
ops.push(RecordOp::Create {
collection: collection.clone(),
@@ -138,7 +139,7 @@ async fn process_single_write(
results.push(WriteResult::UpdateResult {
uri,
cid: record_cid.to_string(),
validation_status: validation_status.map(|s| s.to_string()),
validation_status,
});
ops.push(RecordOp::Update {
collection: collection.clone(),
@@ -237,14 +238,14 @@ pub enum WriteResult {
uri: AtUri,
cid: String,
#[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
validation_status: Option<String>,
validation_status: Option<ValidationStatus>,
},
#[serde(rename = "com.atproto.repo.applyWrites#updateResult")]
UpdateResult {
uri: AtUri,
cid: String,
#[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
validation_status: Option<String>,
validation_status: Option<ValidationStatus>,
},
#[serde(rename = "com.atproto.repo.applyWrites#deleteResult")]
DeleteResult {},
@@ -441,15 +442,7 @@ pub async fn apply_writes(
.await
{
Ok(res) => res,
Err(e) if e.contains("ConcurrentModification") => {
return Err(ApiError::InvalidSwap(Some("Repo has been modified".into())));
}
Err(e) => {
error!("Commit failed: {}", e);
return Err(ApiError::InternalError(Some(
"Failed to commit changes".into(),
)));
}
Err(e) => return Err(ApiError::from(e)),
};
if let Some(ref controller) = controller_did {
@@ -1,6 +1,6 @@
use crate::api::error::ApiError;
use crate::api::repo::record::utils::{
CommitParams, RecordOp, commit_and_log, get_current_root_cid,
CommitError, CommitParams, RecordOp, commit_and_log, get_current_root_cid,
};
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
use crate::auth::{Active, Auth, VerifyScope};
@@ -186,10 +186,7 @@ pub async fn delete_record(
.await
{
Ok(res) => res,
Err(e) if e.contains("ConcurrentModification") => {
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
}
Err(e) => return Ok(ApiError::InternalError(Some(e)).into_response()),
Err(e) => return Ok(ApiError::from(e).into_response()),
};
if let Some(ref controller) = controller_did {
@@ -241,28 +238,30 @@ pub async fn delete_record_internal(
user_id: Uuid,
collection: &Nsid,
rkey: &Rkey,
) -> Result<(), String> {
) -> Result<(), CommitError> {
let _write_lock = state.repo_write_locks.lock(user_id).await;
let root_cid_str = state
.repo_repo
.get_repo_root_cid_by_user_id(user_id)
.await
.map_err(|e| format!("DB error: {}", e))?
.ok_or_else(|| "Repo root not found".to_string())?;
.map_err(|e| CommitError::DatabaseError(e.to_string()))?
.ok_or(CommitError::RepoNotFound)?;
let current_root_cid =
Cid::from_str(root_cid_str.as_str()).map_err(|_| "Invalid repo root CID".to_string())?;
Cid::from_str(root_cid_str.as_str()).map_err(|e| CommitError::InvalidCid(e.to_string()))?;
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = tracking_store
.get(&current_root_cid)
.await
.map_err(|e| format!("Failed to fetch commit: {:?}", e))?
.ok_or_else(|| "Commit block not found".to_string())?;
.map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?
.ok_or(CommitError::BlockStoreFailed(
"Commit block not found".into(),
))?;
let commit =
Commit::from_cbor(&commit_bytes).map_err(|e| format!("Failed to parse commit: {:?}", e))?;
let commit = Commit::from_cbor(&commit_bytes)
.map_err(|e| CommitError::CommitParseFailed(format!("{:?}", e)))?;
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
let key = format!("{}/{}", collection, rkey);
@@ -270,7 +269,7 @@ pub async fn delete_record_internal(
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| format!("MST get error: {:?}", e))?;
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let Some(prev_cid) = prev_record_cid else {
return Ok(());
@@ -279,12 +278,12 @@ pub async fn delete_record_internal(
let new_mst = mst
.delete(&key)
.await
.map_err(|e| format!("Failed to delete from MST: {:?}", e))?;
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let new_mst_root = new_mst
.persist()
.await
.map_err(|e| format!("Failed to persist MST: {:?}", e))?;
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let op = RecordOp::Delete {
collection: collection.clone(),
@@ -298,11 +297,11 @@ pub async fn delete_record_internal(
new_mst
.blocks_for_path(&key, &mut new_mst_blocks)
.await
.map_err(|e| format!("Failed to get new MST blocks: {:?}", e))?;
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
mst.blocks_for_path(&key, &mut old_mst_blocks)
.await
.map_err(|e| format!("Failed to get old MST blocks: {:?}", e))?;
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let mut relevant_blocks = new_mst_blocks.clone();
relevant_blocks.extend(old_mst_blocks.iter().map(|(k, v)| (*k, v.clone())));
@@ -195,7 +195,7 @@ pub async fn list_records(
}
};
let limit = input.limit.unwrap_or(50).clamp(1, 100);
let limit_i64 = limit as i64;
let limit_i64 = i64::from(limit);
let cursor_rkey = input
.cursor
.as_ref()
+119 -56
View File
@@ -14,6 +14,71 @@ use tracing::error;
use tranquil_db_traits::SequenceNumber;
use uuid::Uuid;
#[derive(Debug)]
pub enum CommitError {
InvalidDid(String),
InvalidTid(String),
SigningFailed(String),
SerializationFailed(String),
KeyNotFound,
KeyDecryptionFailed(String),
InvalidKey(String),
BlockStoreFailed(String),
RepoNotFound,
ConcurrentModification,
DatabaseError(String),
UserNotFound,
CommitParseFailed(String),
MstOperationFailed(String),
RecordSerializationFailed(String),
InvalidCid(String),
}
impl std::fmt::Display for CommitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidDid(e) => write!(f, "Invalid DID: {}", e),
Self::InvalidTid(e) => write!(f, "Invalid TID: {}", e),
Self::SigningFailed(e) => write!(f, "Failed to sign commit: {}", e),
Self::SerializationFailed(e) => write!(f, "Failed to serialize signed commit: {}", e),
Self::KeyNotFound => write!(f, "Signing key not found"),
Self::KeyDecryptionFailed(e) => write!(f, "Failed to decrypt signing key: {}", e),
Self::InvalidKey(e) => write!(f, "Invalid signing key: {}", e),
Self::BlockStoreFailed(e) => write!(f, "Block store operation failed: {}", e),
Self::RepoNotFound => write!(f, "Repo not found"),
Self::ConcurrentModification => {
write!(f, "Repo has been modified since last read")
}
Self::DatabaseError(e) => write!(f, "Database error: {}", e),
Self::UserNotFound => write!(f, "User not found"),
Self::CommitParseFailed(e) => write!(f, "Failed to parse commit: {}", e),
Self::MstOperationFailed(e) => write!(f, "MST operation failed: {}", e),
Self::RecordSerializationFailed(e) => {
write!(f, "Failed to serialize record: {}", e)
}
Self::InvalidCid(e) => write!(f, "Invalid CID: {}", e),
}
}
}
impl std::error::Error for CommitError {}
impl From<CommitError> for ApiError {
fn from(err: CommitError) -> Self {
match err {
CommitError::ConcurrentModification => {
ApiError::InvalidSwap(Some("Repo has been modified".into()))
}
CommitError::RepoNotFound => ApiError::RepoNotFound(None),
CommitError::UserNotFound => ApiError::RepoNotFound(Some("User not found".into())),
other => {
error!("Commit failed: {}", other);
ApiError::InternalError(Some("Failed to commit changes".into()))
}
}
}
}
pub async fn get_current_root_cid(state: &AppState, user_id: Uuid) -> Result<CommitCid, ApiError> {
let root_cid_str = state
.repo_repo
@@ -55,7 +120,7 @@ fn extract_blob_cids_recursive(value: &Value, blobs: &mut Vec<String>) {
}
use crate::types::AtUri;
use tranquil_db_traits::Backlink;
use tranquil_db_traits::{Backlink, BacklinkPath};
pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec<Backlink> {
let record_type = record
@@ -71,7 +136,7 @@ pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec<Backlink> {
.map(|subject| {
vec![Backlink {
uri: uri.clone(),
path: "subject".to_string(),
path: BacklinkPath::Subject,
link_to: subject.to_string(),
}]
})
@@ -84,7 +149,7 @@ pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec<Backlink> {
.map(|subject_uri| {
vec![Backlink {
uri: uri.clone(),
path: "subject.uri".to_string(),
path: BacklinkPath::SubjectUri,
link_to: subject_uri.to_string(),
}]
})
@@ -99,19 +164,19 @@ pub fn create_signed_commit(
rev: &str,
prev: Option<Cid>,
signing_key: &SigningKey,
) -> Result<(Vec<u8>, Bytes), String> {
) -> Result<(Vec<u8>, Bytes), CommitError> {
let did = jacquard_common::types::string::Did::new(did.as_str())
.map_err(|e| format!("Invalid DID: {:?}", e))?;
.map_err(|e| CommitError::InvalidDid(format!("{:?}", e)))?;
let rev = jacquard_common::types::string::Tid::from_str(rev)
.map_err(|e| format!("Invalid TID: {:?}", e))?;
.map_err(|e| CommitError::InvalidTid(format!("{:?}", e)))?;
let unsigned = Commit::new_unsigned(did, data, rev, prev);
let signed = unsigned
.sign(signing_key)
.map_err(|e| format!("Failed to sign commit: {:?}", e))?;
.map_err(|e| CommitError::SigningFailed(format!("{:?}", e)))?;
let sig_bytes = signed.sig().clone();
let signed_bytes = signed
.to_cbor()
.map_err(|e| format!("Failed to serialize signed commit: {:?}", e))?;
.map_err(|e| CommitError::SerializationFailed(format!("{:?}", e)))?;
Ok((signed_bytes, sig_bytes))
}
@@ -154,7 +219,7 @@ pub struct CommitParams<'a> {
pub async fn commit_and_log(
state: &AppState,
params: CommitParams<'_>,
) -> Result<CommitResult, String> {
) -> Result<CommitResult, CommitError> {
use tranquil_db_traits::{
ApplyCommitError, ApplyCommitInput, CommitEventData, RecordDelete, RecordUpsert,
RepoEventType,
@@ -175,12 +240,12 @@ pub async fn commit_and_log(
.user_repo
.get_user_key_by_id(user_id)
.await
.map_err(|e| format!("Failed to fetch signing key: {}", e))?
.ok_or_else(|| "Signing key not found".to_string())?;
.map_err(|e| CommitError::DatabaseError(format!("Failed to fetch signing key: {}", e)))?
.ok_or(CommitError::KeyNotFound)?;
let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
.map_err(|e| format!("Failed to decrypt signing key: {}", e))?;
.map_err(|e| CommitError::KeyDecryptionFailed(e.to_string()))?;
let signing_key =
SigningKey::from_slice(&key_bytes).map_err(|e| format!("Invalid signing key: {}", e))?;
SigningKey::from_slice(&key_bytes).map_err(|e| CommitError::InvalidKey(e.to_string()))?;
let rev = Tid::now(LimitedU32::MIN);
let rev_str = rev.to_string();
let (new_commit_bytes, _sig) =
@@ -189,7 +254,7 @@ pub async fn commit_and_log(
.block_store
.put(&new_commit_bytes)
.await
.map_err(|e| format!("Failed to save commit block: {:?}", e))?;
.map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?;
let mut all_block_cids: Vec<Vec<u8>> = blocks_cids
.iter()
@@ -218,7 +283,7 @@ pub async fn commit_and_log(
upserts.push(RecordUpsert {
collection: collection.clone(),
rkey: rkey.clone(),
cid: unsafe { crate::types::CidLink::new_unchecked(cid.to_string()) },
cid: crate::types::CidLink::from(cid),
});
}
RecordOp::Delete {
@@ -283,23 +348,20 @@ pub async fn commit_and_log(
let commit_event = CommitEventData {
did: did.clone(),
event_type: RepoEventType::Commit,
commit_cid: Some(unsafe { crate::types::CidLink::new_unchecked(new_root_cid.to_string()) }),
prev_cid: current_root_cid
.map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }),
commit_cid: Some(crate::types::CidLink::from(new_root_cid)),
prev_cid: current_root_cid.map(crate::types::CidLink::from),
ops: Some(json!(ops_json)),
blobs: Some(blobs.to_vec()),
blocks_cids: Some(blocks_cids.to_vec()),
prev_data_cid: prev_data_cid
.map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }),
prev_data_cid: prev_data_cid.map(crate::types::CidLink::from),
rev: Some(rev_str.clone()),
};
let input = ApplyCommitInput {
user_id,
did: did.clone(),
expected_root_cid: current_root_cid
.map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }),
new_root_cid: unsafe { crate::types::CidLink::new_unchecked(new_root_cid.to_string()) },
expected_root_cid: current_root_cid.map(crate::types::CidLink::from),
new_root_cid: crate::types::CidLink::from(new_root_cid),
new_rev: rev_str.clone(),
new_block_cids: all_block_cids,
obsolete_block_cids: obsolete_bytes,
@@ -313,11 +375,9 @@ pub async fn commit_and_log(
.apply_commit(input)
.await
.map_err(|e| match e {
ApplyCommitError::RepoNotFound => "Repo not found".to_string(),
ApplyCommitError::ConcurrentModification => {
"ConcurrentModification: Repo has been modified since last read".to_string()
}
ApplyCommitError::Database(msg) => format!("DB Error: {}", msg),
ApplyCommitError::RepoNotFound => CommitError::RepoNotFound,
ApplyCommitError::ConcurrentModification => CommitError::ConcurrentModification,
ApplyCommitError::Database(msg) => CommitError::DatabaseError(msg),
})?;
if result.is_account_active {
@@ -335,7 +395,7 @@ pub async fn create_record_internal(
collection: &Nsid,
rkey: &Rkey,
record: &serde_json::Value,
) -> Result<(String, Cid), String> {
) -> Result<(String, Cid), CommitError> {
use crate::repo::tracking::TrackingBlockStore;
use jacquard_repo::mst::Mst;
use std::sync::Arc;
@@ -343,8 +403,8 @@ pub async fn create_record_internal(
.user_repo
.get_id_by_did(did)
.await
.map_err(|e| format!("DB error: {}", e))?
.ok_or_else(|| "User not found".to_string())?;
.map_err(|e| CommitError::DatabaseError(e.to_string()))?
.ok_or(CommitError::UserNotFound)?;
let _write_lock = state.repo_write_locks.lock(user_id).await;
@@ -352,36 +412,38 @@ pub async fn create_record_internal(
.repo_repo
.get_repo_root_cid_by_user_id(user_id)
.await
.map_err(|e| format!("DB error: {}", e))?
.ok_or_else(|| "Repo not found".to_string())?;
let current_root_cid =
Cid::from_str(root_cid_link.as_str()).map_err(|_| "Invalid repo root CID".to_string())?;
.map_err(|e| CommitError::DatabaseError(e.to_string()))?
.ok_or(CommitError::RepoNotFound)?;
let current_root_cid = Cid::from_str(root_cid_link.as_str())
.map_err(|e| CommitError::InvalidCid(e.to_string()))?;
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = tracking_store
.get(&current_root_cid)
.await
.map_err(|e| format!("Failed to fetch commit: {:?}", e))?
.ok_or_else(|| "Commit block not found".to_string())?;
.map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?
.ok_or(CommitError::BlockStoreFailed(
"Commit block not found".into(),
))?;
let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes)
.map_err(|e| format!("Failed to parse commit: {:?}", e))?;
.map_err(|e| CommitError::CommitParseFailed(format!("{:?}", e)))?;
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
let record_ipld = crate::util::json_to_ipld(record);
let mut record_bytes = Vec::new();
serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld)
.map_err(|e| format!("Failed to serialize record: {:?}", e))?;
.map_err(|e| CommitError::RecordSerializationFailed(format!("{:?}", e)))?;
let record_cid = tracking_store
.put(&record_bytes)
.await
.map_err(|e| format!("Failed to save record block: {:?}", e))?;
.map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?;
let key = format!("{}/{}", collection, rkey);
let new_mst = mst
.add(&key, record_cid)
.await
.map_err(|e| format!("Failed to add to MST: {:?}", e))?;
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let new_mst_root = new_mst
.persist()
.await
.map_err(|e| format!("Failed to persist MST: {:?}", e))?;
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let op = RecordOp::Create {
collection: collection.clone(),
rkey: rkey.clone(),
@@ -392,10 +454,10 @@ pub async fn create_record_internal(
new_mst
.blocks_for_path(&key, &mut new_mst_blocks)
.await
.map_err(|e| format!("Failed to get new MST blocks for path: {:?}", e))?;
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
mst.blocks_for_path(&key, &mut old_mst_blocks)
.await
.map_err(|e| format!("Failed to get old MST blocks for path: {:?}", e))?;
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid)
.chain(
old_mst_blocks
@@ -439,36 +501,38 @@ pub async fn sequence_identity_event(
state: &AppState,
did: &Did,
handle: Option<&Handle>,
) -> Result<SequenceNumber, String> {
) -> Result<SequenceNumber, CommitError> {
state
.repo_repo
.insert_identity_event(did, handle)
.await
.map_err(|e| format!("DB Error (identity event): {}", e))
.map_err(|e| CommitError::DatabaseError(format!("identity event: {}", e)))
}
pub async fn sequence_account_event(
state: &AppState,
did: &Did,
status: tranquil_db_traits::AccountStatus,
) -> Result<SequenceNumber, String> {
) -> Result<SequenceNumber, CommitError> {
state
.repo_repo
.insert_account_event(did, status)
.await
.map_err(|e| format!("DB Error (account event): {}", e))
.map_err(|e| CommitError::DatabaseError(format!("account event: {}", e)))
}
pub async fn sequence_sync_event(
state: &AppState,
did: &Did,
commit_cid: &str,
rev: Option<&str>,
) -> Result<SequenceNumber, String> {
let cid_link = unsafe { crate::types::CidLink::new_unchecked(commit_cid) };
) -> Result<SequenceNumber, CommitError> {
let cid_link: crate::types::CidLink = commit_cid
.parse()
.map_err(|_| CommitError::InvalidCid(commit_cid.to_string()))?;
state
.repo_repo
.insert_sync_event(did, &cid_link, rev)
.await
.map_err(|e| format!("DB Error (sync event): {}", e))
.map_err(|e| CommitError::DatabaseError(format!("sync event: {}", e)))
}
pub async fn sequence_genesis_commit(
@@ -477,13 +541,12 @@ pub async fn sequence_genesis_commit(
commit_cid: &Cid,
mst_root_cid: &Cid,
rev: &str,
) -> Result<SequenceNumber, String> {
let commit_cid_link = unsafe { crate::types::CidLink::new_unchecked(commit_cid.to_string()) };
let mst_root_cid_link =
unsafe { crate::types::CidLink::new_unchecked(mst_root_cid.to_string()) };
) -> Result<SequenceNumber, CommitError> {
let commit_cid_link = crate::types::CidLink::from(commit_cid);
let mst_root_cid_link = crate::types::CidLink::from(mst_root_cid);
state
.repo_repo
.insert_genesis_commit_event(did, &commit_cid_link, &mst_root_cid_link, rev)
.await
.map_err(|e| format!("DB Error (genesis commit event): {}", e))
.map_err(|e| CommitError::DatabaseError(format!("genesis commit event: {}", e)))
}
@@ -6,7 +6,7 @@ use crate::api::repo::record::utils::{
get_current_root_cid,
};
use crate::auth::{
Active, Auth, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
Active, Auth, AuthSource, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
require_verified_or_delegated,
};
use crate::cid_types::CommitCid;
@@ -14,6 +14,7 @@ use crate::delegation::DelegationActionType;
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
use crate::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
use crate::validation::ValidationStatus;
use axum::{
Json,
extract::State,
@@ -32,7 +33,7 @@ use uuid::Uuid;
pub struct RepoWriteAuth {
pub did: Did,
pub user_id: Uuid,
pub is_oauth: bool,
pub auth_source: AuthSource,
pub scope: Option<String>,
pub controller_did: Option<Did>,
}
@@ -66,7 +67,7 @@ pub async fn prepare_repo_write<A: RepoScopeAction>(
Ok(RepoWriteAuth {
did: principal_did.into_did(),
user_id,
is_oauth: user.is_oauth(),
auth_source: user.auth_source.clone(),
scope: user.scope.clone(),
controller_did: scope_proof.controller_did().map(|c| c.into_did()),
})
@@ -97,7 +98,7 @@ pub struct CreateRecordOutput {
pub cid: String,
pub commit: CommitInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub validation_status: Option<String>,
pub validation_status: Option<ValidationStatus>,
}
pub async fn create_record(
State(state): State<AppState>,
@@ -323,10 +324,7 @@ pub async fn create_record(
.await
{
Ok(res) => res,
Err(e) if e.contains("ConcurrentModification") => {
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
}
Err(e) => return Ok(ApiError::InternalError(Some(e)).into_response()),
Err(e) => return Ok(ApiError::from(e).into_response()),
};
for conflict_uri in conflict_uris_to_cleanup {
@@ -375,7 +373,7 @@ pub async fn create_record(
cid: commit_result.commit_cid.to_string(),
rev: commit_result.rev,
},
validation_status: validation_status.map(|s| s.to_string()),
validation_status,
}),
)
.into_response())
@@ -402,7 +400,7 @@ pub struct PutRecordOutput {
#[serde(skip_serializing_if = "Option::is_none")]
pub commit: Option<CommitInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub validation_status: Option<String>,
pub validation_status: Option<ValidationStatus>,
}
pub async fn put_record(
State(state): State<AppState>,
@@ -494,7 +492,7 @@ pub async fn put_record(
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
cid: record_cid.to_string(),
commit: None,
validation_status: validation_status.map(|s| s.to_string()),
validation_status,
}),
)
.into_response());
@@ -600,10 +598,7 @@ pub async fn put_record(
.await
{
Ok(res) => res,
Err(e) if e.contains("ConcurrentModification") => {
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
}
Err(e) => return Ok(ApiError::InternalError(Some(e)).into_response()),
Err(e) => return Ok(ApiError::from(e).into_response()),
};
if let Some(ref controller) = controller_did {
@@ -634,7 +629,7 @@ pub async fn put_record(
cid: commit_result.commit_cid.to_string(),
rev: commit_result.rev,
}),
validation_status: validation_status.map(|s| s.to_string()),
validation_status,
}),
)
.into_response())
@@ -285,7 +285,7 @@ async fn assert_valid_did_document_for_service(
arr.iter().find(|svc| {
svc.get("id").and_then(|id| id.as_str()) == Some("#atproto_pds")
|| svc.get("type").and_then(|t| t.as_str())
== Some("AtprotoPersonalDataServer")
== Some(crate::plc::ServiceType::Pds.as_str())
})
})
.and_then(|svc| svc.get("serviceEndpoint"))
@@ -316,7 +316,7 @@ pub async fn activate_account(
);
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth.is_oauth(),
&auth.auth_source,
auth.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Repo,
crate::oauth::scopes::AccountAction::Manage,
@@ -366,10 +366,16 @@ pub async fn activate_account(
did
);
if let Some(ref h) = handle {
let _ = state.cache.delete(&format!("handle:{}", h)).await;
let _ = state.cache.delete(&crate::cache_keys::handle_key(h)).await;
}
let _ = state.cache.delete(&format!("plc:doc:{}", did)).await;
let _ = state.cache.delete(&format!("plc:data:{}", did)).await;
let _ = state
.cache
.delete(&crate::cache_keys::plc_doc_key(&did))
.await;
let _ = state
.cache
.delete(&crate::cache_keys::plc_data_key(&did))
.await;
if state.did_resolver.refresh_did(did.as_str()).await.is_none() {
warn!(
"[MIGRATION] activateAccount: Failed to refresh DID cache for {}",
@@ -479,7 +485,7 @@ pub async fn deactivate_account(
Json(input): Json<DeactivateAccountInput>,
) -> Result<Response, ApiError> {
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth.is_oauth(),
&auth.auth_source,
auth.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Repo,
crate::oauth::scopes::AccountAction::Manage,
@@ -502,7 +508,7 @@ pub async fn deactivate_account(
match result {
Ok(true) => {
if let Some(ref h) = handle {
let _ = state.cache.delete(&format!("handle:{}", h)).await;
let _ = state.cache.delete(&crate::cache_keys::handle_key(h)).await;
}
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
@@ -659,7 +665,10 @@ pub async fn delete_account(
);
}
}
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
let _ = state
.cache
.delete(&crate::cache_keys::handle_key(&handle))
.await;
info!("Account {} deleted successfully", did);
EmptyResponse::ok().into_response()
}
@@ -150,8 +150,9 @@ pub async fn create_app_password(
ApiError::InternalError(None)
})?;
let privilege =
tranquil_db_traits::AppPasswordPrivilege::from(input.privileged.unwrap_or(false));
let privilege = tranquil_db_traits::AppPasswordPrivilege::from_privileged_flag(
input.privileged.unwrap_or(false),
);
let created_at = chrono::Utc::now();
let create_data = AppPasswordCreate {
@@ -232,7 +233,7 @@ pub async fn revoke_app_password(
.log_db_err("revoking sessions for app password")?;
futures::future::join_all(sessions_to_invalidate.iter().map(|jti| {
let cache_key = format!("auth:session:{}:{}", &auth.did, jti);
let cache_key = crate::cache_keys::session_key(&auth.did, jti);
let cache = state.cache.clone();
async move {
let _ = cache.delete(&cache_key).await;
+22 -44
View File
@@ -21,7 +21,7 @@ use tranquil_db_traits::CommsChannel;
const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60);
fn email_update_cache_key(did: &str) -> String {
format!("email_update:{}", did)
crate::cache_keys::email_update_key(did)
}
fn hash_token(token: &str) -> String {
@@ -51,7 +51,7 @@ pub async fn request_email_update(
input: Option<Json<RequestEmailUpdateInput>>,
) -> Result<Response, ApiError> {
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth.is_oauth(),
&auth.auth_source,
auth.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Email,
crate::oauth::scopes::AccountAction::Manage,
@@ -111,7 +111,6 @@ pub async fn request_email_update(
state.infra_repo.as_ref(),
user.id,
&token,
"email_update",
hostname,
)
.await
@@ -138,7 +137,7 @@ pub async fn confirm_email(
Json(input): Json<ConfirmEmailInput>,
) -> Result<Response, ApiError> {
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth.is_oauth(),
&auth.auth_source,
auth.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Email,
crate::oauth::scopes::AccountAction::Manage,
@@ -173,13 +172,13 @@ pub async fn confirm_email(
let verified = crate::auth::verification_token::verify_signup_token(
&confirmation_code,
"email",
CommsChannel::Email,
&provided_email,
);
match verified {
Ok(token_data) => {
if token_data.did != did.as_str() {
if token_data.did != *did {
return Err(ApiError::InvalidToken(None));
}
}
@@ -216,7 +215,7 @@ pub async fn update_email(
Json(input): Json<UpdateEmailInput>,
) -> Result<Response, ApiError> {
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth.is_oauth(),
&auth.auth_source,
auth.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Email,
crate::oauth::scopes::AccountAction::Manage,
@@ -324,13 +323,13 @@ pub async fn update_email(
let verified = crate::auth::verification_token::verify_channel_update_token(
&confirmation_token,
"email_update",
CommsChannel::Email,
&current_email_lower,
);
match verified {
Ok(token_data) => {
if token_data.did != did.as_str() {
if token_data.did != *did {
return Err(ApiError::InvalidToken(None));
}
}
@@ -361,8 +360,11 @@ pub async fn update_email(
.await
.log_db_err("updating email")?;
let verification_token =
crate::auth::verification_token::generate_signup_token(did, "email", &new_email);
let verification_token = crate::auth::verification_token::generate_signup_token(
did,
CommsChannel::Email,
&new_email,
);
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
let hostname = pds_hostname();
@@ -370,7 +372,7 @@ pub async fn update_email(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
"email",
tranquil_db_traits::CommsChannel::Email,
&new_email,
&formatted_token,
hostname,
@@ -422,8 +424,8 @@ pub async fn check_email_verified(
#[derive(Deserialize)]
pub struct CheckChannelVerifiedInput {
pub did: String,
pub channel: String,
pub did: crate::types::Did,
pub channel: CommsChannel,
}
pub async fn check_channel_verified(
@@ -431,23 +433,9 @@ pub async fn check_channel_verified(
_rate_limit: RateLimited<VerificationCheckLimit>,
Json(input): Json<CheckChannelVerifiedInput>,
) -> Response {
let channel = match input.channel.to_lowercase().as_str() {
"email" => CommsChannel::Email,
"discord" => CommsChannel::Discord,
"telegram" => CommsChannel::Telegram,
"signal" => CommsChannel::Signal,
_ => {
return ApiError::InvalidRequest("invalid channel".into()).into_response();
}
};
let did = match crate::Did::new(input.did) {
Ok(d) => d,
Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(),
};
match state
.user_repo
.check_channel_verified_by_did(&did, channel)
.check_channel_verified_by_did(&input.did, input.channel)
.await
{
Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(),
@@ -490,9 +478,9 @@ pub async fn authorize_email_update(
);
return ApiError::InvalidToken(None).into_response();
}
if token_data.channel != "email_update" {
if token_data.channel != CommsChannel::Email {
warn!(
"authorize_email_update: wrong channel: {}",
"authorize_email_update: wrong channel: {:?}",
token_data.channel
);
return ApiError::InvalidToken(None).into_response();
@@ -558,7 +546,7 @@ pub async fn check_email_update_status(
auth: Auth<NotTakendown>,
) -> Result<Response, ApiError> {
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth.is_oauth(),
&auth.auth_source,
auth.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Email,
crate::oauth::scopes::AccountAction::Read,
@@ -620,7 +608,7 @@ pub async fn check_email_in_use(
#[derive(Deserialize)]
pub struct CheckCommsChannelInUseInput {
pub channel: String,
pub channel: CommsChannel,
pub identifier: String,
}
@@ -629,16 +617,6 @@ pub async fn check_comms_channel_in_use(
_rate_limit: RateLimited<VerificationCheckLimit>,
Json(input): Json<CheckCommsChannelInUseInput>,
) -> Response {
let channel = match input.channel.to_lowercase().as_str() {
"email" => CommsChannel::Email,
"discord" => CommsChannel::Discord,
"telegram" => CommsChannel::Telegram,
"signal" => CommsChannel::Signal,
_ => {
return ApiError::InvalidRequest("invalid channel".into()).into_response();
}
};
let identifier = input.identifier.trim();
if identifier.is_empty() {
return ApiError::InvalidRequest("identifier is required".into()).into_response();
@@ -646,7 +624,7 @@ pub async fn check_comms_channel_in_use(
let count = match state
.user_repo
.count_accounts_by_comms_identifier(channel, identifier)
.count_accounts_by_comms_identifier(input.channel, identifier)
.await
{
Ok(c) => c,
+1 -1
View File
@@ -226,7 +226,7 @@ pub async fn get_account_invite_codes(
})
.unwrap_or_default();
let use_count = uses.len() as i32;
let use_count = i32::try_from(uses.len()).unwrap_or(i32::MAX);
if !include_used && use_count >= info.available_uses {
return None;
}
+5 -2
View File
@@ -21,7 +21,10 @@ pub async fn get_logo(State(state): State<AppState>) -> Response {
Some(c) if !c.is_empty() => c,
_ => return StatusCode::NOT_FOUND.into_response(),
};
let cid = unsafe { crate::types::CidLink::new_unchecked(&cid_str) };
let cid = match crate::types::CidLink::new(&cid_str) {
Ok(c) => c,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let metadata = match state.blob_repo.get_blob_metadata(&cid).await {
Ok(Some(m)) => m,
@@ -38,7 +41,7 @@ pub async fn get_logo(State(state): State<AppState>) -> Response {
.header(header::CONTENT_TYPE, &metadata.mime_type)
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(Body::from(data))
.unwrap(),
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
Err(e) => {
error!("Failed to fetch logo from storage: {:?}", e);
StatusCode::NOT_FOUND.into_response()
+7 -8
View File
@@ -3,16 +3,17 @@ use crate::util::{discord_app_id, discord_bot_username, pds_hostname, telegram_b
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde_json::json;
fn get_available_comms_channels() -> Vec<&'static str> {
let mut channels = vec!["email"];
fn get_available_comms_channels() -> Vec<tranquil_db_traits::CommsChannel> {
use tranquil_db_traits::CommsChannel;
let mut channels = vec![CommsChannel::Email];
if std::env::var("DISCORD_BOT_TOKEN").is_ok() {
channels.push("discord");
channels.push(CommsChannel::Discord);
}
if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() {
channels.push("telegram");
channels.push(CommsChannel::Telegram);
}
if std::env::var("SIGNAL_CLI_PATH").is_ok() && std::env::var("SIGNAL_SENDER_NUMBER").is_ok() {
channels.push("signal");
channels.push(CommsChannel::Signal);
}
channels
}
@@ -35,9 +36,7 @@ pub async fn describe_server() -> impl IntoResponse {
let domains_str =
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.to_string());
let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect();
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let invite_code_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
let privacy_policy = std::env::var("PRIVACY_POLICY_URL").ok();
let terms_of_service = std::env::var("TERMS_OF_SERVICE_URL").ok();
let contact_email = std::env::var("CONTACT_EMAIL").ok();
@@ -196,7 +196,7 @@ async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_
})).collect::<Vec<_>>(),
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"type": crate::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": service_endpoint
}]
});
@@ -244,7 +244,7 @@ async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_
}],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"type": crate::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": service_endpoint
}]
})
@@ -16,13 +16,14 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use tracing::{debug, error, info, warn};
use tranquil_db_traits::WebauthnChallengeType;
use uuid::Uuid;
use crate::api::repo::record::utils::create_signed_commit;
use crate::auth::{ServiceTokenVerifier, generate_app_password, is_service_token};
use crate::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited};
use crate::state::AppState;
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
use crate::types::{Did, Handle, PlainPassword};
use crate::util::{pds_hostname, pds_hostname_without_port};
use crate::validation::validate_password;
@@ -49,7 +50,7 @@ pub struct CreatePasskeyAccountInput {
pub did: Option<String>,
pub did_type: Option<String>,
pub signing_key: Option<String>,
pub verification_channel: Option<String>,
pub verification_channel: Option<tranquil_db_traits::CommsChannel>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_username: Option<String>,
@@ -73,7 +74,7 @@ pub async fn create_passkey_account(
Json(input): Json<CreatePasskeyAccountInput>,
) -> Response {
let byod_auth = if let Some(extracted) = crate::auth::extract_auth_token_from_header(
crate::util::get_header_str(&headers, "Authorization"),
crate::util::get_header_str(&headers, http::header::AUTHORIZATION),
) {
let token = extracted.token;
if is_service_token(&token) {
@@ -152,22 +153,22 @@ pub async fn create_passkey_account(
Err(_) => return ApiError::InvalidInviteCode.into_response(),
}
} else {
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED");
if invite_required {
return ApiError::InviteCodeRequired.into_response();
}
None
};
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
let verification_channel = input
.verification_channel
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
let verification_recipient = match verification_channel {
"email" => match &email {
tranquil_db_traits::CommsChannel::Email => match &email {
Some(e) if !e.is_empty() => e.clone(),
_ => return ApiError::MissingEmail.into_response(),
},
"discord" => match &input.discord_username {
tranquil_db_traits::CommsChannel::Discord => match &input.discord_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().to_lowercase();
if !crate::api::validation::is_valid_discord_username(&clean) {
@@ -179,7 +180,7 @@ pub async fn create_passkey_account(
}
_ => return ApiError::MissingDiscordId.into_response(),
},
"telegram" => match &input.telegram_username {
tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().trim_start_matches('@');
if !crate::api::validation::is_valid_telegram_username(clean) {
@@ -191,13 +192,12 @@ pub async fn create_passkey_account(
}
_ => return ApiError::MissingTelegramUsername.into_response(),
},
"signal" => match &input.signal_username {
tranquil_db_traits::CommsChannel::Signal => match &input.signal_username {
Some(username) if !username.trim().is_empty() => {
username.trim().trim_start_matches('@').to_lowercase()
}
_ => return ApiError::MissingSignalNumber.into_response(),
},
_ => return ApiError::InvalidVerificationChannel.into_response(),
};
use k256::ecdsa::SigningKey;
@@ -277,7 +277,7 @@ pub async fn create_passkey_account(
)
.await
{
return ApiError::InvalidDid(e).into_response();
return ApiError::InvalidDid(e.to_string()).into_response();
}
info!(did = %d, "Creating external did:web passkey account (reserved key)");
}
@@ -380,7 +380,10 @@ pub async fn create_passkey_account(
}
};
let rev = Tid::now(LimitedU32::MIN);
let did_typed = unsafe { Did::new_unchecked(&did) };
let did_typed: Did = match did.parse() {
Ok(d) => d,
Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(),
};
let (commit_bytes, _sig) =
match create_signed_commit(&did_typed, mst_root, rev.as_ref(), None, &secret_key) {
Ok(result) => result,
@@ -405,20 +408,15 @@ pub async fn create_passkey_account(
})
});
let preferred_comms_channel = match verification_channel {
"email" => tranquil_db_traits::CommsChannel::Email,
"discord" => tranquil_db_traits::CommsChannel::Discord,
"telegram" => tranquil_db_traits::CommsChannel::Telegram,
"signal" => tranquil_db_traits::CommsChannel::Signal,
_ => tranquil_db_traits::CommsChannel::Email,
let handle_typed: Handle = match handle.parse() {
Ok(h) => h,
Err(_) => return ApiError::InvalidHandle(None).into_response(),
};
let handle_typed = unsafe { Handle::new_unchecked(&handle) };
let create_input = tranquil_db_traits::CreatePasskeyAccountInput {
handle: handle_typed.clone(),
email: email.clone().unwrap_or_default(),
did: did_typed.clone(),
preferred_comms_channel,
preferred_comms_channel: verification_channel,
discord_username: input
.discord_username
.as_deref()
@@ -487,13 +485,11 @@ pub async fn create_passkey_account(
"$type": "app.bsky.actor.profile",
"displayName": handle
});
let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") };
let profile_rkey = unsafe { Rkey::new_unchecked("self") };
if let Err(e) = crate::api::repo::record::create_record_internal(
&state,
&did_typed,
&profile_collection,
&profile_rkey,
&crate::types::PROFILE_COLLECTION,
&crate::types::PROFILE_RKEY,
&profile_record,
)
.await
@@ -503,7 +499,7 @@ pub async fn create_passkey_account(
}
let verification_token = crate::auth::verification_token::generate_signup_token(
&did,
&did_typed,
verification_channel,
&verification_recipient,
);
@@ -625,7 +621,7 @@ pub async fn complete_passkey_setup(
let reg_state = match state
.user_repo
.load_webauthn_challenge(&input.did, "registration")
.load_webauthn_challenge(&input.did, WebauthnChallengeType::Registration)
.await
{
Ok(Some(json)) => match serde_json::from_str(&json) {
@@ -706,7 +702,7 @@ pub async fn complete_passkey_setup(
let _ = state
.user_repo
.delete_webauthn_challenge(&input.did, "registration")
.delete_webauthn_challenge(&input.did, WebauthnChallengeType::Registration)
.await;
info!(did = %input.did, "Passkey-only account setup completed");
@@ -793,7 +789,7 @@ pub async fn start_passkey_registration_for_setup(
};
if let Err(e) = state
.user_repo
.save_webauthn_challenge(&input.did, "registration", &state_json)
.save_webauthn_challenge(&input.did, WebauthnChallengeType::Registration, &state_json)
.await
{
error!("Failed to save registration state: {:?}", e);
@@ -9,6 +9,7 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use tracing::{error, info, warn};
use tranquil_db_traits::WebauthnChallengeType;
use webauthn_rs::prelude::*;
#[derive(Deserialize)]
@@ -64,7 +65,7 @@ pub async fn start_passkey_registration(
state
.user_repo
.save_webauthn_challenge(&auth.did, "registration", &state_json)
.save_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration, &state_json)
.await
.log_db_err("saving registration state")?;
@@ -98,7 +99,7 @@ pub async fn finish_passkey_registration(
let reg_state_json = state
.user_repo
.load_webauthn_challenge(&auth.did, "registration")
.load_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration)
.await
.log_db_err("loading registration state")?
.ok_or(ApiError::NoRegistrationInProgress)?;
@@ -140,7 +141,7 @@ pub async fn finish_passkey_registration(
if let Err(e) = state
.user_repo
.delete_webauthn_challenge(&auth.did, "registration")
.delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration)
.await
{
warn!("Failed to delete registration state: {:?}", e);
@@ -171,7 +171,7 @@ pub async fn reset_password(
}
};
futures::future::join_all(result.session_jtis.iter().map(|jti| {
let cache_key = format!("auth:session:{}:{}", result.did, jti);
let cache_key = crate::cache_keys::session_key(&result.did, jti);
let cache = state.cache.clone();
async move {
if let Err(e) = cache.delete(&cache_key).await {
+31 -16
View File
@@ -8,7 +8,7 @@ use axum::{
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::{error, info, warn};
use tranquil_db_traits::{SessionRepository, UserRepository};
use tranquil_db_traits::{SessionRepository, UserRepository, WebauthnChallengeType};
use crate::auth::{Active, Auth};
use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
@@ -17,12 +17,20 @@ use crate::types::PlainPassword;
pub const REAUTH_WINDOW_SECONDS: i64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ReauthMethod {
Password,
Totp,
Passkey,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReauthStatusResponse {
pub last_reauth_at: Option<DateTime<Utc>>,
pub reauth_required: bool,
pub available_methods: Vec<String>,
pub available_methods: Vec<ReauthMethod>,
}
pub async fn get_reauth_status(
@@ -180,7 +188,11 @@ pub async fn reauth_passkey_start(
state
.user_repo
.save_webauthn_challenge(&auth.did, "authentication", &state_json)
.save_webauthn_challenge(
&auth.did,
WebauthnChallengeType::Authentication,
&state_json,
)
.await
.log_db_err("saving authentication state")?;
@@ -201,7 +213,7 @@ pub async fn reauth_passkey_finish(
) -> Result<Response, ApiError> {
let auth_state_json = state
.user_repo
.load_webauthn_challenge(&auth.did, "authentication")
.load_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
.await
.log_db_err("loading authentication state")?
.ok_or(ApiError::NoChallengeInProgress)?;
@@ -229,14 +241,17 @@ pub async fn reauth_passkey_finish(
let cred_id_bytes = auth_result.cred_id().as_ref();
match state
.user_repo
.update_passkey_counter(cred_id_bytes, auth_result.counter() as i32)
.update_passkey_counter(
cred_id_bytes,
i32::try_from(auth_result.counter()).unwrap_or(i32::MAX),
)
.await
{
Ok(false) => {
warn!(did = %&auth.did, "Passkey counter anomaly detected - possible cloned key");
let _ = state
.user_repo
.delete_webauthn_challenge(&auth.did, "authentication")
.delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
.await;
return Err(ApiError::PasskeyCounterAnomaly);
}
@@ -248,7 +263,7 @@ pub async fn reauth_passkey_finish(
let _ = state
.user_repo
.delete_webauthn_challenge(&auth.did, "authentication")
.delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
.await;
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
@@ -265,12 +280,12 @@ pub async fn update_last_reauth_cached(
did: &crate::types::Did,
) -> Result<DateTime<Utc>, tranquil_db_traits::DbError> {
let now = session_repo.update_last_reauth(did).await?;
let cache_key = format!("reauth:{}", did);
let cache_key = crate::cache_keys::reauth_key(did);
let _ = cache
.set(
&cache_key,
&now.timestamp().to_string(),
std::time::Duration::from_secs(REAUTH_WINDOW_SECONDS as u64),
std::time::Duration::from_secs(u64::try_from(REAUTH_WINDOW_SECONDS).unwrap_or(300)),
)
.await;
Ok(now)
@@ -290,7 +305,7 @@ async fn get_available_reauth_methods(
user_repo: &dyn UserRepository,
_session_repo: &dyn SessionRepository,
did: &crate::types::Did,
) -> Vec<String> {
) -> Vec<ReauthMethod> {
let mut methods = Vec::new();
let has_password = user_repo
@@ -301,17 +316,17 @@ async fn get_available_reauth_methods(
.is_some();
if has_password {
methods.push("password".to_string());
methods.push(ReauthMethod::Password);
}
let has_totp = user_repo.has_totp_enabled(did).await.unwrap_or(false);
if has_totp {
methods.push("totp".to_string());
methods.push(ReauthMethod::Totp);
}
let has_passkeys = user_repo.has_passkeys(did).await.unwrap_or(false);
if has_passkeys {
methods.push("passkey".to_string());
methods.push(ReauthMethod::Passkey);
}
methods
@@ -332,7 +347,7 @@ pub async fn check_reauth_required_cached(
cache: &std::sync::Arc<dyn crate::cache::Cache>,
did: &crate::types::Did,
) -> bool {
let cache_key = format!("reauth:{}", did);
let cache_key = crate::cache_keys::reauth_key(did);
if let Some(timestamp_str) = cache.get(&cache_key).await
&& let Ok(timestamp) = timestamp_str.parse::<i64>()
{
@@ -355,7 +370,7 @@ pub async fn check_reauth_required_cached(
pub struct ReauthRequiredError {
pub error: String,
pub message: String,
pub reauth_methods: Vec<String>,
pub reauth_methods: Vec<ReauthMethod>,
}
pub async fn reauth_required_response(
@@ -428,5 +443,5 @@ pub async fn legacy_mfa_required_response(
pub struct MfaVerificationRequiredError {
pub error: String,
pub message: String,
pub reauth_methods: Vec<String>,
pub reauth_methods: Vec<ReauthMethod>,
}
@@ -2,6 +2,7 @@ use crate::AccountStatus;
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::types::Did;
use axum::http::Method;
use axum::{
Json,
extract::{Query, State},
@@ -10,34 +11,44 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashSet;
use std::sync::LazyLock;
use tracing::{error, info, warn};
use tranquil_types::Nsid;
static CREATE_ACCOUNT_NSID: LazyLock<Nsid> =
LazyLock::new(|| "com.atproto.server.createAccount".parse().unwrap());
const HOUR_SECS: i64 = 3600;
const MINUTE_SECS: i64 = 60;
const PROTECTED_METHODS: &[&str] = &[
"com.atproto.admin.sendEmail",
"com.atproto.identity.requestPlcOperationSignature",
"com.atproto.identity.signPlcOperation",
"com.atproto.identity.updateHandle",
"com.atproto.server.activateAccount",
"com.atproto.server.confirmEmail",
"com.atproto.server.createAppPassword",
"com.atproto.server.deactivateAccount",
"com.atproto.server.getAccountInviteCodes",
"com.atproto.server.getSession",
"com.atproto.server.listAppPasswords",
"com.atproto.server.requestAccountDelete",
"com.atproto.server.requestEmailConfirmation",
"com.atproto.server.requestEmailUpdate",
"com.atproto.server.revokeAppPassword",
"com.atproto.server.updateEmail",
];
static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
[
"com.atproto.admin.sendEmail",
"com.atproto.identity.requestPlcOperationSignature",
"com.atproto.identity.signPlcOperation",
"com.atproto.identity.updateHandle",
"com.atproto.server.activateAccount",
"com.atproto.server.confirmEmail",
"com.atproto.server.createAppPassword",
"com.atproto.server.deactivateAccount",
"com.atproto.server.getAccountInviteCodes",
"com.atproto.server.getSession",
"com.atproto.server.listAppPasswords",
"com.atproto.server.requestAccountDelete",
"com.atproto.server.requestEmailConfirmation",
"com.atproto.server.requestEmailUpdate",
"com.atproto.server.revokeAppPassword",
"com.atproto.server.updateEmail",
]
.into_iter()
.collect()
});
#[derive(Deserialize)]
pub struct GetServiceAuthParams {
pub aud: String,
pub lxm: Option<String>,
pub aud: Did,
pub lxm: Option<Nsid>,
pub exp: Option<i64>,
}
@@ -51,8 +62,8 @@ pub async fn get_service_auth(
headers: axum::http::HeaderMap,
Query(params): Query<GetServiceAuthParams>,
) -> Response {
let auth_header = crate::util::get_header_str(&headers, "Authorization");
let dpop_proof = crate::util::get_header_str(&headers, "DPoP");
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(),
@@ -68,40 +79,47 @@ pub async fn get_service_auth(
}
};
let (token, is_dpop) = if auth_header.len() >= 7
&& auth_header[..7].eq_ignore_ascii_case("bearer ")
{
(auth_header[7..].trim().to_string(), false)
} else if auth_header.len() >= 5 && auth_header[..5].eq_ignore_ascii_case("dpop ") {
(auth_header[5..].trim().to_string(), true)
} else {
warn!(auth_scheme = ?auth_header.split_whitespace().next(), "getServiceAuth: invalid auth scheme");
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 is_dpop {
let auth_user = if extracted.scheme.is_dpop() {
match crate::oauth::verify::verify_oauth_access_token(
state.oauth_repo.as_ref(),
&token,
dpop_proof,
"GET",
Method::GET.as_str(),
&crate::util::build_full_url(&format!(
"/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}",
params.aud,
params.lxm.as_deref().unwrap_or("")
params.lxm.as_ref().map_or("", |n| n.as_str())
)),
)
.await
{
Ok(result) => crate::auth::AuthenticatedUser {
did: unsafe { Did::new_unchecked(result.did) },
is_admin: false,
status: AccountStatus::Active,
scope: result.scope,
key_bytes: None,
controller_did: None,
auth_source: crate::auth::AuthSource::OAuth,
},
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,
@@ -179,15 +197,15 @@ pub async fn get_service_auth(
}
};
let lxm = params.lxm.as_deref();
let lxm_for_token = lxm.unwrap_or("*");
let lxm = params.lxm.as_ref();
let lxm_for_token = lxm.map_or("*", |n| n.as_str());
if let Some(method) = lxm {
if let Err(e) = crate::auth::scope_check::check_rpc_scope(
auth_user.is_oauth(),
&auth_user.auth_source,
auth_user.scope.as_deref(),
&params.aud,
method,
params.aud.as_str(),
method.as_str(),
) {
return e;
}
@@ -209,12 +227,12 @@ pub async fn get_service_auth(
.flatten()
.is_some_and(|s| s.takedown_ref.is_some());
if is_takendown && lxm != Some("com.atproto.server.createAccount") {
if is_takendown && lxm != Some(&*CREATE_ACCOUNT_NSID) {
return ApiError::InvalidToken(Some("Bad token scope".into())).into_response();
}
if let Some(method) = lxm
&& PROTECTED_METHODS.contains(&method)
&& PROTECTED_METHODS.contains(&method.as_str())
{
return ApiError::InvalidRequest(format!(
"cannot request a service auth token for the following protected method: {}",
@@ -248,7 +266,7 @@ pub async fn get_service_auth(
let service_token = match crate::auth::create_service_token(
&auth_user.did,
&params.aud,
params.aud.as_str(),
lxm_for_token,
&key_bytes,
) {
+36 -54
View File
@@ -266,7 +266,7 @@ pub async fn create_session(
refresh_jti: refresh_meta.jti.clone(),
access_expires_at: access_meta.expires_at,
refresh_expires_at: refresh_meta.expires_at,
login_type: tranquil_db_traits::LoginType::from(is_legacy_login),
login_type: tranquil_db_traits::LoginType::from_legacy_flag(is_legacy_login),
mfa_verified: false,
scope: app_password_scopes.clone(),
controller_did: app_password_controller.clone(),
@@ -338,12 +338,6 @@ pub async fn get_session(
);
match db_result {
Ok(Some(row)) => {
let preferred_channel = match row.preferred_comms_channel {
tranquil_db_traits::CommsChannel::Email => "email",
tranquil_db_traits::CommsChannel::Discord => "discord",
tranquil_db_traits::CommsChannel::Telegram => "telegram",
tranquil_db_traits::CommsChannel::Signal => "signal",
};
let preferred_channel_verified = row
.channel_verification
.is_verified(row.preferred_comms_channel);
@@ -365,7 +359,7 @@ pub async fn get_session(
"handle": handle,
"did": &auth.did,
"active": account_state.is_active(),
"preferredChannel": preferred_channel,
"preferredChannel": row.preferred_comms_channel.as_str(),
"preferredChannelVerified": preferred_channel_verified,
"preferredLocale": row.preferred_locale,
"isAdmin": row.is_admin
@@ -404,7 +398,7 @@ pub async fn delete_session(
) -> Result<Response, ApiError> {
let extracted = crate::auth::extract_auth_token_from_header(crate::util::get_header_str(
&headers,
"Authorization",
http::header::AUTHORIZATION,
))
.ok_or(ApiError::AuthenticationRequired)?;
let jti = crate::auth::get_jti_from_token(&extracted.token)
@@ -413,7 +407,7 @@ pub async fn delete_session(
match state.session_repo.delete_session_by_access_jti(&jti).await {
Ok(rows) if rows > 0 => {
if let Some(did) = did {
let session_cache_key = format!("auth:session:{}:{}", did, jti);
let session_cache_key = crate::cache_keys::session_key(&did, &jti);
let _ = state.cache.delete(&session_cache_key).await;
}
Ok(EmptyResponse::ok().into_response())
@@ -430,7 +424,7 @@ pub async fn refresh_session(
) -> Response {
let extracted = match crate::auth::extract_auth_token_from_header(crate::util::get_header_str(
&headers,
"Authorization",
http::header::AUTHORIZATION,
)) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
@@ -548,12 +542,6 @@ pub async fn refresh_session(
);
match db_result {
Ok(Some(u)) => {
let preferred_channel = match u.preferred_comms_channel {
tranquil_db_traits::CommsChannel::Email => "email",
tranquil_db_traits::CommsChannel::Discord => "discord",
tranquil_db_traits::CommsChannel::Telegram => "telegram",
tranquil_db_traits::CommsChannel::Signal => "signal",
};
let preferred_channel_verified = u
.channel_verification
.is_verified(u.preferred_comms_channel);
@@ -568,7 +556,7 @@ pub async fn refresh_session(
"did": session_row.did,
"email": u.email,
"emailConfirmed": u.channel_verification.email,
"preferredChannel": preferred_channel,
"preferredChannel": u.preferred_comms_channel.as_str(),
"preferredChannelVerified": preferred_channel_verified,
"preferredLocale": u.preferred_locale,
"isAdmin": u.is_admin,
@@ -609,7 +597,7 @@ pub struct ConfirmSignupOutput {
pub did: Did,
pub email: Option<String>,
pub email_verified: bool,
pub preferred_channel: String,
pub preferred_channel: tranquil_db_traits::CommsChannel,
pub preferred_channel_verified: bool,
}
@@ -631,29 +619,26 @@ pub async fn confirm_signup(
}
};
let (channel_str, identifier) = match row.channel {
tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
let identifier = match row.channel {
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(),
tranquil_db_traits::CommsChannel::Discord => {
("discord", row.discord_username.clone().unwrap_or_default())
row.discord_username.clone().unwrap_or_default()
}
tranquil_db_traits::CommsChannel::Telegram => (
"telegram",
row.telegram_username.clone().unwrap_or_default(),
),
tranquil_db_traits::CommsChannel::Signal => {
("signal", row.signal_username.clone().unwrap_or_default())
tranquil_db_traits::CommsChannel::Telegram => {
row.telegram_username.clone().unwrap_or_default()
}
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(),
};
let normalized_token =
crate::auth::verification_token::normalize_token_input(&input.verification_code);
match crate::auth::verification_token::verify_signup_token(
&normalized_token,
channel_str,
row.channel,
&identifier,
) {
Ok(token_data) => {
if token_data.did != input.did.as_str() {
if token_data.did != input.did {
warn!(
"Token DID mismatch for confirm_signup: expected {}, got {}",
input.did, token_data.did
@@ -733,21 +718,14 @@ pub async fn confirm_signup(
{
warn!("Failed to enqueue welcome notification: {:?}", e);
}
let email_verified = matches!(row.channel, tranquil_db_traits::CommsChannel::Email);
let preferred_channel = match row.channel {
tranquil_db_traits::CommsChannel::Email => "email",
tranquil_db_traits::CommsChannel::Discord => "discord",
tranquil_db_traits::CommsChannel::Telegram => "telegram",
tranquil_db_traits::CommsChannel::Signal => "signal",
};
Json(ConfirmSignupOutput {
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
handle: row.handle,
did: row.did,
email: row.email,
email_verified,
preferred_channel: preferred_channel.to_string(),
email_verified: matches!(row.channel, tranquil_db_traits::CommsChannel::Email),
preferred_channel: row.channel,
preferred_channel_verified: true,
})
.into_response()
@@ -783,22 +761,19 @@ pub async fn resend_verification(
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
}
let (channel_str, recipient) = match row.channel {
tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
let recipient = match row.channel {
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(),
tranquil_db_traits::CommsChannel::Discord => {
("discord", row.discord_username.clone().unwrap_or_default())
row.discord_username.clone().unwrap_or_default()
}
tranquil_db_traits::CommsChannel::Telegram => (
"telegram",
row.telegram_username.clone().unwrap_or_default(),
),
tranquil_db_traits::CommsChannel::Signal => {
("signal", row.signal_username.clone().unwrap_or_default())
tranquil_db_traits::CommsChannel::Telegram => {
row.telegram_username.clone().unwrap_or_default()
}
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(),
};
let verification_token =
crate::auth::verification_token::generate_signup_token(&input.did, channel_str, &recipient);
crate::auth::verification_token::generate_signup_token(&input.did, row.channel, &recipient);
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
@@ -807,7 +782,7 @@ pub async fn resend_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
row.id,
channel_str,
row.channel,
&recipient,
&formatted_token,
hostname,
@@ -819,11 +794,18 @@ pub async fn resend_verification(
SuccessResponse::ok().into_response()
}
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionType {
Legacy,
OAuth,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
pub id: String,
pub session_type: String,
pub session_type: SessionType,
pub client_name: Option<String>,
pub created_at: String,
pub expires_at: String,
@@ -861,7 +843,7 @@ pub async fn list_sessions(
let jwt_sessions = jwt_rows.into_iter().map(|row| SessionInfo {
id: format!("jwt:{}", row.id),
session_type: "legacy".to_string(),
session_type: SessionType::Legacy,
client_name: None,
created_at: row.created_at.to_rfc3339(),
expires_at: row.refresh_expires_at.to_rfc3339(),
@@ -874,7 +856,7 @@ pub async fn list_sessions(
let is_current_oauth = is_oauth && current_jti.as_deref() == Some(row.token_id.as_str());
SessionInfo {
id: format!("oauth:{}", row.id),
session_type: "oauth".to_string(),
session_type: SessionType::OAuth,
client_name: Some(client_name),
created_at: row.created_at.to_rfc3339(),
expires_at: row.expires_at.to_rfc3339(),
@@ -925,7 +907,7 @@ pub async fn revoke_session(
.delete_session_by_id(session_id)
.await
.log_db_err("deleting session")?;
let cache_key = format!("auth:session:{}:{}", &auth.did, access_jti);
let cache_key = crate::cache_keys::session_key(&auth.did, &access_jti);
if let Err(e) = state.cache.delete(&cache_key).await {
warn!("Failed to invalidate session cache: {:?}", e);
}
@@ -25,7 +25,7 @@ fn public_key_to_did_key(signing_key: &SigningKey) -> String {
#[derive(Deserialize)]
pub struct ReserveSigningKeyInput {
pub did: Option<String>,
pub did: Option<crate::types::Did>,
}
#[derive(Serialize)]
@@ -38,13 +38,6 @@ pub async fn reserve_signing_key(
State(state): State<AppState>,
Json(input): Json<ReserveSigningKeyInput>,
) -> Response {
let did: Option<crate::types::Did> = match input.did {
Some(ref d) => match d.parse() {
Ok(parsed) => Some(parsed),
Err(_) => return ApiError::InvalidDid("Invalid DID format".into()).into_response(),
},
None => None,
};
let signing_key = SigningKey::random(&mut rand::thread_rng());
let private_key_bytes = signing_key.to_bytes();
let public_key_did_key = public_key_to_did_key(&signing_key);
@@ -52,7 +45,12 @@ pub async fn reserve_signing_key(
let private_bytes: &[u8] = &private_key_bytes;
match state
.infra_repo
.reserve_signing_key(did.as_ref(), &public_key_did_key, private_bytes, expires_at)
.reserve_signing_key(
input.did.as_ref(),
&public_key_did_key,
private_bytes,
expires_at,
)
.await
{
Ok(key_id) => {
@@ -103,7 +103,7 @@ pub async fn list_trusted_devices(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevokeTrustedDeviceInput {
pub device_id: String,
pub device_id: DeviceId,
}
pub async fn revoke_trusted_device(
@@ -111,10 +111,9 @@ pub async fn revoke_trusted_device(
auth: Auth<Active>,
Json(input): Json<RevokeTrustedDeviceInput>,
) -> Result<Response, ApiError> {
let device_id = DeviceId::from(input.device_id.clone());
match state
.oauth_repo
.device_belongs_to_user(&device_id, &auth.did)
.device_belongs_to_user(&input.device_id, &auth.did)
.await
{
Ok(true) => {}
@@ -129,7 +128,7 @@ pub async fn revoke_trusted_device(
state
.oauth_repo
.revoke_device_trust(&device_id)
.revoke_device_trust(&input.device_id)
.await
.log_db_err("revoking device trust")?;
@@ -140,7 +139,7 @@ pub async fn revoke_trusted_device(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateTrustedDeviceInput {
pub device_id: String,
pub device_id: DeviceId,
pub friendly_name: Option<String>,
}
@@ -149,10 +148,9 @@ pub async fn update_trusted_device(
auth: Auth<Active>,
Json(input): Json<UpdateTrustedDeviceInput>,
) -> Result<Response, ApiError> {
let device_id = DeviceId::from(input.device_id.clone());
match state
.oauth_repo
.device_belongs_to_user(&device_id, &auth.did)
.device_belongs_to_user(&input.device_id, &auth.did)
.await
{
Ok(true) => {}
@@ -167,7 +165,7 @@ pub async fn update_trusted_device(
state
.oauth_repo
.update_device_friendly_name(&device_id, input.friendly_name.as_deref())
.update_device_friendly_name(&input.device_id, input.friendly_name.as_deref())
.await
.log_db_err("updating device friendly name")?;
@@ -177,14 +175,10 @@ pub async fn update_trusted_device(
pub async fn get_device_trust_state(
oauth_repo: &dyn OAuthRepository,
device_id: &str,
device_id: &DeviceId,
did: &tranquil_types::Did,
) -> DeviceTrustState {
let device_id_typed = DeviceId::from(device_id.to_string());
match oauth_repo
.get_device_trust_info(&device_id_typed, did)
.await
{
match oauth_repo.get_device_trust_info(device_id, did).await {
Ok(Some(info)) => DeviceTrustState::from_timestamps(info.trusted_at, info.trusted_until),
_ => DeviceTrustState::Untrusted,
}
@@ -192,7 +186,7 @@ pub async fn get_device_trust_state(
pub async fn is_device_trusted(
oauth_repo: &dyn OAuthRepository,
device_id: &str,
device_id: &DeviceId,
did: &tranquil_types::Did,
) -> bool {
get_device_trust_state(oauth_repo, device_id, did)
@@ -202,23 +196,19 @@ pub async fn is_device_trusted(
pub async fn trust_device(
oauth_repo: &dyn OAuthRepository,
device_id: &str,
device_id: &DeviceId,
) -> Result<(), tranquil_db_traits::DbError> {
let now = Utc::now();
let trusted_until = now + Duration::days(TRUST_DURATION_DAYS);
let device_id_typed = DeviceId::from(device_id.to_string());
oauth_repo
.trust_device(&device_id_typed, now, trusted_until)
.await
oauth_repo.trust_device(device_id, now, trusted_until).await
}
pub async fn extend_device_trust(
oauth_repo: &dyn OAuthRepository,
device_id: &str,
device_id: &DeviceId,
) -> Result<(), tranquil_db_traits::DbError> {
let trusted_until = Utc::now() + Duration::days(TRUST_DURATION_DAYS);
let device_id_typed = DeviceId::from(device_id.to_string());
oauth_repo
.extend_device_trust(&device_id_typed, trusted_until)
.extend_device_trust(device_id, trusted_until)
.await
}
@@ -10,6 +10,7 @@ use crate::auth::verification_token::{
VerificationPurpose, normalize_token_input, verify_token_signature,
};
use crate::state::AppState;
use tranquil_db_traits::CommsChannel;
#[derive(Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
@@ -23,8 +24,8 @@ pub struct VerifyTokenInput {
pub struct VerifyTokenOutput {
pub success: bool,
pub did: Did,
pub purpose: String,
pub channel: String,
pub purpose: VerificationPurpose,
pub channel: CommsChannel,
}
pub async fn verify_token(
@@ -53,14 +54,14 @@ pub async fn verify_token_internal(
match token_data.purpose {
VerificationPurpose::Migration => {
handle_migration_verification(state, &token_data.did, &token_data.channel, &identifier)
handle_migration_verification(state, &token_data.did, token_data.channel, &identifier)
.await
}
VerificationPurpose::ChannelUpdate => {
handle_channel_update(state, &token_data.did, &token_data.channel, &identifier).await
handle_channel_update(state, &token_data.did, token_data.channel, &identifier).await
}
VerificationPurpose::Signup => {
handle_signup_verification(state, &token_data.did, &token_data.channel, &identifier)
handle_signup_verification(state, &token_data.did, token_data.channel, &identifier)
.await
}
}
@@ -68,20 +69,17 @@ pub async fn verify_token_internal(
async fn handle_migration_verification(
state: &AppState,
did: &str,
channel: &str,
did: &Did,
channel: CommsChannel,
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, ApiError> {
if channel != "email" {
if channel != CommsChannel::Email {
return Err(ApiError::InvalidChannel);
}
let did_typed: Did = did
.parse()
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
let user = state
.user_repo
.get_verification_info(&did_typed)
.get_verification_info(did)
.await
.log_db_err("during migration verification")?
.ok_or(ApiError::AccountNotFound)?;
@@ -102,30 +100,27 @@ async fn handle_migration_verification(
Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string().into(),
purpose: "migration".to_string(),
channel: channel.to_string(),
did: did.clone(),
purpose: VerificationPurpose::Migration,
channel,
}))
}
async fn handle_channel_update(
state: &AppState,
did: &str,
channel: &str,
did: &Did,
channel: CommsChannel,
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, ApiError> {
let did_typed: Did = did
.parse()
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
let user_id = state
.user_repo
.get_id_by_did(&did_typed)
.get_id_by_did(did)
.await
.log_db_err("fetching user id")?
.ok_or(ApiError::AccountNotFound)?;
match channel {
"email" => {
CommsChannel::Email => {
let success = state
.user_repo
.verify_email_channel(user_id, identifier)
@@ -135,33 +130,30 @@ async fn handle_channel_update(
return Err(ApiError::EmailTaken);
}
}
"discord" => {
CommsChannel::Discord => {
state
.user_repo
.verify_discord_channel(user_id, identifier)
.await
.log_db_err("updating discord channel")?;
}
"telegram" => {
CommsChannel::Telegram => {
state
.user_repo
.verify_telegram_channel(user_id, identifier)
.await
.log_db_err("updating telegram channel")?;
}
"signal" => {
CommsChannel::Signal => {
state
.user_repo
.verify_signal_channel(user_id, identifier)
.await
.log_db_err("updating signal channel")?;
}
_ => {
return Err(ApiError::InvalidChannel);
}
};
info!(did = %did, channel = %channel, "Channel verified successfully");
info!(did = %did, channel = ?channel, "Channel verified successfully");
let recipient = resolve_verified_recipient(state, user_id, channel, identifier).await;
if let Err(e) = comms_repo::enqueue_channel_verified(
@@ -179,20 +171,20 @@ async fn handle_channel_update(
Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string().into(),
purpose: "channel_update".to_string(),
channel: channel.to_string(),
did: did.clone(),
purpose: VerificationPurpose::ChannelUpdate,
channel,
}))
}
async fn resolve_verified_recipient(
state: &AppState,
user_id: uuid::Uuid,
channel: &str,
channel: tranquil_db_traits::CommsChannel,
identifier: &str,
) -> String {
match channel {
"telegram" => state
tranquil_db_traits::CommsChannel::Telegram => state
.user_repo
.get_telegram_chat_id(user_id)
.await
@@ -206,16 +198,13 @@ async fn resolve_verified_recipient(
async fn handle_signup_verification(
state: &AppState,
did: &str,
channel: &str,
did: &Did,
channel: CommsChannel,
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, ApiError> {
let did_typed: Did = did
.parse()
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
let user = state
.user_repo
.get_verification_info(&did_typed)
.get_verification_info(did)
.await
.log_db_err("during signup verification")?
.ok_or(ApiError::AccountNotFound)?;
@@ -225,47 +214,44 @@ async fn handle_signup_verification(
info!(did = %did, "Account already verified");
return Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string().into(),
purpose: "signup".to_string(),
channel: channel.to_string(),
did: did.clone(),
purpose: VerificationPurpose::Signup,
channel,
}));
}
match channel {
"email" => {
CommsChannel::Email => {
state
.user_repo
.set_email_verified_flag(user.id)
.await
.log_db_err("updating email verified status")?;
}
"discord" => {
CommsChannel::Discord => {
state
.user_repo
.set_discord_verified_flag(user.id)
.await
.log_db_err("updating discord verified status")?;
}
"telegram" => {
CommsChannel::Telegram => {
state
.user_repo
.set_telegram_verified_flag(user.id)
.await
.log_db_err("updating telegram verified status")?;
}
"signal" => {
CommsChannel::Signal => {
state
.user_repo
.set_signal_verified_flag(user.id)
.await
.log_db_err("updating signal verified status")?;
}
_ => {
return Err(ApiError::InvalidChannel);
}
};
info!(did = %did, channel = %channel, "Signup verified successfully");
info!(did = %did, channel = ?channel, "Signup verified successfully");
let recipient = resolve_verified_recipient(state, user.id, channel, identifier).await;
if let Err(e) = comms_repo::enqueue_channel_verified(
@@ -283,8 +269,8 @@ async fn handle_signup_verification(
Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string().into(),
purpose: "signup".to_string(),
channel: channel.to_string(),
did: did.clone(),
purpose: VerificationPurpose::Signup,
channel,
}))
}
@@ -86,7 +86,7 @@ pub async fn handle_telegram_webhook(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
"telegram",
tranquil_db_traits::CommsChannel::Telegram,
&from.id.to_string(),
pds_hostname(),
)
+1 -1
View File
@@ -57,7 +57,7 @@ pub async fn dereference_scope(
for part in scope_parts {
if let Some(cid_str) = part.strip_prefix("ref:") {
let cache_key = format!("scope_ref:{}", cid_str);
let cache_key = crate::cache_keys::scope_ref_key(cid_str);
if let Some(cached) = state.cache.get(&cache_key).await {
for s in cached.split_whitespace() {
if !resolved_scopes.contains(&s.to_string()) {
+18 -7
View File
@@ -23,7 +23,7 @@ impl ValidatedLocalHandle {
}
pub fn new_allow_reserved(handle: impl AsRef<str>) -> Result<Self, HandleValidationError> {
let validated = validate_service_handle(handle.as_ref(), true)?;
let validated = validate_service_handle(handle.as_ref(), ReservedHandlePolicy::Allow)?;
Ok(Self(validated))
}
@@ -252,13 +252,19 @@ impl std::fmt::Display for HandleValidationError {
impl std::error::Error for HandleValidationError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReservedHandlePolicy {
Allow,
Reject,
}
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
validate_service_handle(handle, false)
validate_service_handle(handle, ReservedHandlePolicy::Reject)
}
pub fn validate_service_handle(
handle: &str,
allow_reserved: bool,
reserved_policy: ReservedHandlePolicy,
) -> Result<String, HandleValidationError> {
let handle = handle.trim();
@@ -301,7 +307,9 @@ pub fn validate_service_handle(
return Err(HandleValidationError::BannedWord);
}
if !allow_reserved && crate::handle::reserved::is_reserved_subdomain(handle) {
if reserved_policy == ReservedHandlePolicy::Reject
&& crate::handle::reserved::is_reserved_subdomain(handle)
{
return Err(HandleValidationError::Reserved);
}
@@ -501,12 +509,15 @@ mod tests {
#[test]
fn test_allow_reserved() {
assert_eq!(
validate_service_handle("admin", true),
validate_service_handle("admin", ReservedHandlePolicy::Allow),
Ok("admin".to_string())
);
assert_eq!(validate_service_handle("api", true), Ok("api".to_string()));
assert_eq!(
validate_service_handle("admin", false),
validate_service_handle("api", ReservedHandlePolicy::Allow),
Ok("api".to_string())
);
assert_eq!(
validate_service_handle("admin", ReservedHandlePolicy::Reject),
Err(HandleValidationError::Reserved)
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ use serde::Deserialize;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmChannelVerificationInput {
pub channel: String,
pub channel: tranquil_db_traits::CommsChannel,
pub identifier: String,
pub code: String,
}
+51 -21
View File
@@ -6,6 +6,18 @@ use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{debug, error, info, warn};
#[derive(Debug, thiserror::Error)]
pub enum DidResolutionError {
#[error("Invalid did:web format")]
InvalidDidWeb,
#[error("HTTP request failed: {0}")]
HttpFailed(String),
#[error("Invalid DID document: {0}")]
InvalidDocument(String),
#[error("DID not found")]
NotFound,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DidDocument {
pub id: String,
@@ -78,10 +90,10 @@ impl DidResolver {
}
}
fn build_did_web_url(did: &str) -> Result<String, String> {
fn build_did_web_url(did: &str) -> Result<String, DidResolutionError> {
let host = did
.strip_prefix("did:web:")
.ok_or("Invalid did:web format")?;
.ok_or(DidResolutionError::InvalidDidWeb)?;
let (host, path) = if host.contains(':') {
let decoded = host.replace("%3A", ":");
@@ -184,7 +196,7 @@ impl DidResolver {
self.extract_service_endpoint(&doc)
}
async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, String> {
async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, DidResolutionError> {
let url = Self::build_did_web_url(did)?;
debug!("Resolving did:web {} via {}", did, url);
@@ -194,18 +206,21 @@ impl DidResolver {
.get(&url)
.send()
.await
.map_err(|e| format!("HTTP request failed: {}", e))?;
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
return Err(DidResolutionError::HttpFailed(format!(
"HTTP {}",
resp.status()
)));
}
resp.json::<DidDocument>()
.await
.map_err(|e| format!("Failed to parse DID document: {}", e))
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
}
async fn resolve_did_plc(&self, did: &str) -> Result<DidDocument, String> {
async fn resolve_did_plc(&self, did: &str) -> Result<DidDocument, DidResolutionError> {
let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
debug!("Resolving did:plc {} via {}", did, url);
@@ -215,24 +230,27 @@ impl DidResolver {
.get(&url)
.send()
.await
.map_err(|e| format!("HTTP request failed: {}", e))?;
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err("DID not found".to_string());
return Err(DidResolutionError::NotFound);
}
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
return Err(DidResolutionError::HttpFailed(format!(
"HTTP {}",
resp.status()
)));
}
resp.json::<DidDocument>()
.await
.map_err(|e| format!("Failed to parse DID document: {}", e))
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
}
fn extract_service_endpoint(&self, doc: &DidDocument) -> Option<ResolvedService> {
if let Some(service) = doc.service.iter().find(|s| {
s.service_type == "AtprotoAppView"
s.service_type == crate::plc::ServiceType::AppView.as_str()
|| s.id.contains("atproto_appview")
|| s.id.ends_with("#bsky_appview")
}) {
@@ -329,7 +347,10 @@ impl DidResolver {
}
}
async fn fetch_did_document_web(&self, did: &str) -> Result<serde_json::Value, String> {
async fn fetch_did_document_web(
&self,
did: &str,
) -> Result<serde_json::Value, DidResolutionError> {
let url = Self::build_did_web_url(did)?;
let resp = self
@@ -337,18 +358,24 @@ impl DidResolver {
.get(&url)
.send()
.await
.map_err(|e| format!("HTTP request failed: {}", e))?;
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
return Err(DidResolutionError::HttpFailed(format!(
"HTTP {}",
resp.status()
)));
}
resp.json::<serde_json::Value>()
.await
.map_err(|e| format!("Failed to parse DID document: {}", e))
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
}
async fn fetch_did_document_plc(&self, did: &str) -> Result<serde_json::Value, String> {
async fn fetch_did_document_plc(
&self,
did: &str,
) -> Result<serde_json::Value, DidResolutionError> {
let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
let resp = self
@@ -356,19 +383,22 @@ impl DidResolver {
.get(&url)
.send()
.await
.map_err(|e| format!("HTTP request failed: {}", e))?;
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err("DID not found".to_string());
return Err(DidResolutionError::NotFound);
}
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
return Err(DidResolutionError::HttpFailed(format!(
"HTTP {}",
resp.status()
)));
}
resp.json::<serde_json::Value>()
.await
.map_err(|e| format!("Failed to parse DID document: {}", e))
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
}
pub async fn invalidate_cache(&self, did: &str) {
+1 -1
View File
@@ -55,7 +55,7 @@ fn generate_short_token() -> String {
}
fn current_timestamp() -> u64 {
chrono::Utc::now().timestamp().max(0) as u64
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
}
pub async fn create_email_token(
+19 -7
View File
@@ -64,9 +64,21 @@ impl IntoResponse for AuthError {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthScheme {
Bearer,
DPoP,
}
impl AuthScheme {
pub fn is_dpop(self) -> bool {
matches!(self, Self::DPoP)
}
}
pub struct ExtractedToken {
pub token: String,
pub is_dpop: bool,
pub scheme: AuthScheme,
}
pub fn extract_bearer_token_from_header(auth_header: Option<&str>) -> Option<String> {
@@ -100,7 +112,7 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option<Extra
}
return Some(ExtractedToken {
token: token.to_string(),
is_dpop: false,
scheme: AuthScheme::Bearer,
});
}
@@ -111,7 +123,7 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option<Extra
}
return Some(ExtractedToken {
token: token.to_string(),
is_dpop: true,
scheme: AuthScheme::DPoP,
});
}
@@ -255,7 +267,7 @@ async fn verify_oauth_token_and_build_user(
}
}
async fn verify_service_token(token: &str) -> Result<ServiceTokenClaims, AuthError> {
async fn verify_service_token_claims(token: &str) -> Result<ServiceTokenClaims, AuthError> {
let verifier = ServiceTokenVerifier::new();
let claims = verifier
.verify_service_token(token, None)
@@ -289,11 +301,11 @@ async fn extract_auth_internal(
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
if is_service_token(&extracted.token) {
let claims = verify_service_token(&extracted.token).await?;
let claims = verify_service_token_claims(&extracted.token).await?;
return Ok(ExtractedAuth::Service(claims));
}
let dpop_proof = crate::util::get_header_str(&parts.headers, "DPoP");
let dpop_proof = crate::util::get_header_str(&parts.headers, crate::util::HEADER_DPOP);
let method = parts.method.as_str();
let original_uri = parts
.extensions
@@ -418,7 +430,7 @@ pub struct ServiceAuth {
impl ServiceAuth {
pub fn require_lxm(&self, expected_lxm: &str) -> Result<(), ApiError> {
match &self.claims.lxm {
Some(lxm) if lxm == "*" || lxm == expected_lxm => Ok(()),
Some(lxm) if crate::auth::lxm_permits(lxm, expected_lxm) => Ok(()),
Some(lxm) => Err(ApiError::AuthorizationError(format!(
"Token lxm '{}' does not permit '{}'",
lxm, expected_lxm
+1 -1
View File
@@ -135,7 +135,7 @@ fn generate_code() -> String {
}
fn current_timestamp() -> u64 {
Utc::now().timestamp().max(0) as u64
u64::try_from(Utc::now().timestamp()).unwrap_or(0)
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
+100 -38
View File
@@ -26,8 +26,9 @@ pub use login_identifier::{BareLoginIdentifier, NormalizedLoginIdentifier};
pub use account_verified::{AccountVerified, require_not_migrated, require_verified_or_delegated};
pub use extractor::{
Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, ExtractedToken, NotTakendown,
Permissive, ServiceAuth, extract_auth_token_from_header, extract_bearer_token_from_header,
Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, AuthScheme, ExtractedToken,
NotTakendown, Permissive, ServiceAuth, extract_auth_token_from_header,
extract_bearer_token_from_header,
};
pub use mfa_verified::{
MfaMethod, MfaVerified, require_legacy_session_mfa, require_reauth_window,
@@ -39,12 +40,11 @@ pub use scope_verified::{
RpcCall, ScopeAction, ScopeVerificationError, ScopeVerified, VerifyScope, WriteOpKind,
verify_batch_write_scopes,
};
pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
pub use service::{ServiceTokenClaims, ServiceTokenError, ServiceTokenVerifier, is_service_token};
pub use tranquil_auth::{
ActClaim, Claims, Header, SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED,
SCOPE_REFRESH, TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenData,
TokenVerifyError, TokenWithMetadata, UnsafeClaims, create_access_token,
ActClaim, Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
TokenVerifyError, TokenWithMetadata, TotpError, UnsafeClaims, create_access_token,
create_access_token_hs256, create_access_token_hs256_with_metadata,
create_access_token_with_delegation, create_access_token_with_metadata,
create_access_token_with_scope_metadata, create_refresh_token, create_refresh_token_hs256,
@@ -56,11 +56,18 @@ pub use tranquil_auth::{
verify_refresh_token, verify_refresh_token_hs256, verify_token, verify_totp_code,
};
pub fn encrypt_totp_secret(secret: &[u8]) -> Result<Vec<u8>, String> {
pub fn lxm_permits(lxm: &str, expected: &str) -> bool {
lxm == "*" || lxm == expected
}
pub fn encrypt_totp_secret(secret: &[u8]) -> Result<Vec<u8>, crate::config::CryptoError> {
crate::config::encrypt_key(secret)
}
pub fn decrypt_totp_secret(encrypted: &[u8], version: i32) -> Result<Vec<u8>, String> {
pub fn decrypt_totp_secret(
encrypted: &[u8],
version: i32,
) -> Result<Vec<u8>, crate::config::CryptoError> {
crate::config::decrypt_key(encrypted, Some(version))
}
@@ -113,6 +120,7 @@ impl fmt::Display for TokenValidationError {
}
}
#[derive(Debug, Clone)]
pub enum AuthSource {
Session,
OAuth,
@@ -162,7 +170,7 @@ impl AuthenticatedUser {
pub fn require_lxm(&self, expected_lxm: &str) -> Result<(), ApiError> {
match self.auth_source.service_claims() {
Some(claims) => match &claims.lxm {
Some(lxm) if lxm == "*" || lxm == expected_lxm => Ok(()),
Some(lxm) if lxm_permits(lxm, expected_lxm) => Ok(()),
Some(lxm) => Err(ApiError::AuthorizationError(format!(
"Token lxm '{}' does not permit '{}'",
lxm, expected_lxm
@@ -192,7 +200,7 @@ impl AuthenticatedUser {
impl AuthenticatedUser {
pub fn permissions(&self) -> ScopePermissions {
if let Some(ref scope) = self.scope
&& scope != SCOPE_ACCESS
&& scope != TokenScope::Access.as_str()
{
return ScopePermissions::from_scope_string(Some(scope));
}
@@ -265,7 +273,7 @@ async fn validate_bearer_token_with_options_internal(
Ok(d) => d,
Err(_) => return Err(TokenValidationError::InvalidToken),
};
let key_cache_key = format!("auth:key:{}", did_str);
let key_cache_key = crate::cache_keys::signing_key_key(did_str);
let mut cached_key: Option<Vec<u8>> = None;
if let Some(c) = cache {
@@ -279,7 +287,7 @@ async fn validate_bearer_token_with_options_internal(
let (decrypted_key, deactivated_at, takedown_ref, is_admin) = if let Some(key) = cached_key
{
let status_cache_key = format!("auth:status:{}", did_str);
let status_cache_key = crate::cache_keys::user_status_key(did_str);
let cached_status: Option<CachedUserStatus> = if let Some(c) = cache {
c.get(&status_cache_key)
.await
@@ -347,7 +355,7 @@ async fn validate_bearer_token_with_options_internal(
)
.await;
let status_cache_key = format!("auth:status:{}", did);
let status_cache_key = crate::cache_keys::user_status_key(&did.to_string());
let cached = CachedUserStatus {
deactivated: user.deactivated_at.is_some(),
takendown: user.takedown_ref.is_some(),
@@ -386,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 = format!("auth:session:{}:{}", did, jti);
let session_cache_key = crate::cache_keys::session_key(&did, &jti);
let mut session_valid = false;
if let Some(c) = cache {
@@ -424,11 +432,14 @@ async fn validate_bearer_token_with_options_internal(
}
if session_valid {
let controller_did = token_data
.claims
.act
.as_ref()
.map(|a| unsafe { Did::new_unchecked(a.sub.clone()) });
let controller_did: Option<Did> = match &token_data.claims.act {
Some(act) => Some(
act.sub
.parse()
.map_err(|_| TokenValidationError::InvalidToken)?,
),
None => None,
};
let status =
AccountStatus::from_db_fields(takedown_ref.as_deref(), deactivated_at);
return Ok(AuthenticatedUser {
@@ -479,15 +490,22 @@ async fn validate_bearer_token_with_options_internal(
} else {
None
};
let did: Did = oauth_token
.did
.parse()
.map_err(|_| TokenValidationError::InvalidToken)?;
let controller_did: Option<Did> = oauth_info
.controller_did
.map(|d| d.parse())
.transpose()
.map_err(|_| TokenValidationError::InvalidToken)?;
return Ok(AuthenticatedUser {
did: unsafe { Did::new_unchecked(oauth_token.did) },
did,
key_bytes,
is_admin: oauth_token.is_admin,
status,
scope: oauth_info.scope,
controller_did: oauth_info
.controller_did
.map(|d| unsafe { Did::new_unchecked(d) }),
controller_did,
auth_source: AuthSource::OAuth,
});
} else {
@@ -499,33 +517,45 @@ async fn validate_bearer_token_with_options_internal(
}
pub async fn invalidate_auth_cache(cache: &dyn Cache, did: &str) {
let key_cache_key = format!("auth:key:{}", did);
let status_cache_key = format!("auth:status:{}", did);
let key_cache_key = crate::cache_keys::signing_key_key(did);
let status_cache_key = crate::cache_keys::user_status_key(did);
let _ = cache.delete(&key_cache_key).await;
let _ = cache.delete(&status_cache_key).await;
}
#[allow(clippy::too_many_arguments)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountRequirement {
Active,
NotTakendown,
AnyStatus,
}
pub async fn validate_token_with_dpop(
user_repo: &dyn UserRepository,
oauth_repo: &dyn OAuthRepository,
token: &str,
is_dpop_token: bool,
scheme: AuthScheme,
dpop_proof: Option<&str>,
http_method: &str,
http_uri: &str,
allow_deactivated: bool,
allow_takendown: bool,
requirement: AccountRequirement,
) -> Result<AuthenticatedUser, TokenValidationError> {
if !is_dpop_token {
if allow_takendown {
return validate_bearer_token_allow_takendown(user_repo, token).await;
} else if allow_deactivated {
return validate_bearer_token_allow_deactivated(user_repo, token).await;
} else {
return validate_bearer_token(user_repo, token).await;
}
if !scheme.is_dpop() {
return match requirement {
AccountRequirement::AnyStatus => {
validate_bearer_token_allow_takendown(user_repo, token).await
}
AccountRequirement::NotTakendown => {
validate_bearer_token_allow_deactivated(user_repo, token).await
}
AccountRequirement::Active => validate_bearer_token(user_repo, token).await,
};
}
let (allow_deactivated, allow_takendown) = match requirement {
AccountRequirement::Active => (false, false),
AccountRequirement::NotTakendown => (true, false),
AccountRequirement::AnyStatus => (true, true),
};
match crate::oauth::verify::verify_oauth_access_token(
oauth_repo,
token,
@@ -566,7 +596,7 @@ pub async fn validate_token_with_dpop(
None
};
Ok(AuthenticatedUser {
did: unsafe { Did::new_unchecked(result.did) },
did: result_did,
key_bytes,
is_admin: user_info.is_admin,
status,
@@ -581,3 +611,35 @@ pub async fn validate_token_with_dpop(
Err(_) => Err(TokenValidationError::AuthenticationFailed),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lxm_permits_exact_match() {
assert!(lxm_permits(
"com.atproto.repo.uploadBlob",
"com.atproto.repo.uploadBlob"
));
}
#[test]
fn test_lxm_permits_wildcard() {
assert!(lxm_permits("*", "com.atproto.repo.uploadBlob"));
assert!(lxm_permits("*", "anything.at.all"));
}
#[test]
fn test_lxm_permits_mismatch() {
assert!(!lxm_permits(
"com.atproto.repo.uploadBlob",
"com.atproto.repo.createRecord"
));
}
#[test]
fn test_lxm_permits_partial_not_wildcard() {
assert!(!lxm_permits("com.atproto.*", "com.atproto.repo.uploadBlob"));
}
}
+22 -15
View File
@@ -7,22 +7,25 @@ use crate::oauth::scopes::{
AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions,
};
use super::SCOPE_ACCESS;
use super::{AuthSource, TokenScope};
fn has_custom_scope(scope: Option<&str>) -> bool {
match scope {
None => false,
Some(s) => s != SCOPE_ACCESS,
fn requires_scope_check(auth_source: &AuthSource, scope: Option<&str>) -> bool {
match auth_source {
AuthSource::OAuth => true,
_ => match scope {
None => false,
Some(s) => s != TokenScope::Access.as_str(),
},
}
}
pub fn check_repo_scope(
is_oauth: bool,
auth_source: &AuthSource,
scope: Option<&str>,
action: RepoAction,
collection: &str,
) -> Result<(), Response> {
if !is_oauth && !has_custom_scope(scope) {
if !requires_scope_check(auth_source, scope) {
return Ok(());
}
@@ -32,8 +35,12 @@ pub fn check_repo_scope(
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
}
pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Result<(), Response> {
if !is_oauth && !has_custom_scope(scope) {
pub fn check_blob_scope(
auth_source: &AuthSource,
scope: Option<&str>,
mime: &str,
) -> Result<(), Response> {
if !requires_scope_check(auth_source, scope) {
return Ok(());
}
@@ -44,12 +51,12 @@ pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Resu
}
pub fn check_rpc_scope(
is_oauth: bool,
auth_source: &AuthSource,
scope: Option<&str>,
aud: &str,
lxm: &str,
) -> Result<(), Response> {
if !is_oauth && !has_custom_scope(scope) {
if !requires_scope_check(auth_source, scope) {
return Ok(());
}
@@ -60,12 +67,12 @@ pub fn check_rpc_scope(
}
pub fn check_account_scope(
is_oauth: bool,
auth_source: &AuthSource,
scope: Option<&str>,
attr: AccountAttr,
action: AccountAction,
) -> Result<(), Response> {
if !is_oauth && !has_custom_scope(scope) {
if !requires_scope_check(auth_source, scope) {
return Ok(());
}
@@ -76,11 +83,11 @@ pub fn check_account_scope(
}
pub fn check_identity_scope(
is_oauth: bool,
auth_source: &AuthSource,
scope: Option<&str>,
attr: IdentityAttr,
) -> Result<(), Response> {
if !is_oauth && !has_custom_scope(scope) {
if !requires_scope_check(auth_source, scope) {
return Ok(());
}
+180 -92
View File
@@ -1,6 +1,4 @@
use crate::types::Did;
use crate::util::pds_hostname;
use anyhow::{Result, anyhow};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
@@ -9,6 +7,77 @@ use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tracing::debug;
use tranquil_types::Did;
#[derive(Debug, thiserror::Error)]
pub enum ServiceTokenError {
#[error("Invalid token format")]
InvalidFormat,
#[error("Base64 decode failed")]
Base64Decode(#[source] base64::DecodeError),
#[error("JSON decode failed")]
JsonDecode(#[source] serde_json::Error),
#[error("Unsupported algorithm: {0}")]
UnsupportedAlgorithm(super::SigningAlgorithm),
#[error("Token expired")]
Expired,
#[error("Invalid audience: expected {expected}, got {actual}")]
InvalidAudience { expected: Did, actual: Did },
#[error("Token lxm '{token_lxm}' does not permit '{required}'")]
LxmMismatch { token_lxm: String, required: String },
#[error("Token missing lxm claim")]
MissingLxm,
#[error("Invalid signature format")]
InvalidSignature(#[source] k256::ecdsa::Error),
#[error("Signature verification failed")]
SignatureVerificationFailed(#[source] k256::ecdsa::Error),
#[error("No atproto verification method found")]
NoVerificationMethod,
#[error("Verification method missing publicKeyMultibase")]
MissingPublicKey,
#[error("Unsupported DID method")]
UnsupportedDidMethod,
#[error("DID not found: {0}")]
DidNotFound(String),
#[error("HTTP request failed")]
HttpFailed(#[source] reqwest::Error),
#[error("Failed to parse DID document")]
InvalidDidDocument(#[source] reqwest::Error),
#[error("HTTP {0}")]
HttpStatus(reqwest::StatusCode),
#[error("Invalid multibase encoding")]
InvalidMultibase(#[source] multibase::Error),
#[error("Invalid multicodec data")]
InvalidMulticodec,
#[error("Unsupported key type: expected secp256k1")]
UnsupportedKeyType,
#[error("Invalid public key")]
InvalidPublicKey(#[source] k256::ecdsa::Error),
}
struct JwtParts<'a> {
header: &'a str,
claims: &'a str,
signature: &'a str,
}
impl<'a> JwtParts<'a> {
fn parse(token: &'a str) -> Result<Self, ServiceTokenError> {
let mut parts = token.splitn(4, '.');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(header), Some(claims), Some(signature), None) => Ok(Self {
header,
claims,
signature,
}),
_ => Err(ServiceTokenError::InvalidFormat),
}
}
fn signing_input(&self) -> String {
format!("{}.{}", self.header, self.claims)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -48,9 +117,9 @@ pub struct ServiceTokenClaims {
#[serde(default)]
pub sub: Option<Did>,
pub aud: Did,
pub exp: usize,
pub exp: i64,
#[serde(default)]
pub iat: Option<usize>,
pub iat: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lxm: Option<String>,
#[serde(default)]
@@ -65,14 +134,14 @@ impl ServiceTokenClaims {
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TokenHeader {
pub alg: String,
pub typ: String,
pub alg: super::SigningAlgorithm,
pub typ: super::TokenType,
}
pub struct ServiceTokenVerifier {
client: Client,
plc_directory_url: String,
pds_did: String,
pds_did: Did,
}
impl ServiceTokenVerifier {
@@ -81,7 +150,9 @@ impl ServiceTokenVerifier {
.unwrap_or_else(|_| "https://plc.directory".to_string());
let pds_hostname = pds_hostname();
let pds_did = format!("did:web:{}", pds_hostname);
let pds_did: Did = format!("did:web:{}", pds_hostname)
.parse()
.expect("PDS hostname produces a valid DID");
let client = Client::builder()
.timeout(Duration::from_secs(10))
@@ -102,55 +173,50 @@ impl ServiceTokenVerifier {
&self,
token: &str,
required_lxm: Option<&str>,
) -> Result<ServiceTokenClaims> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err(anyhow!("Invalid token format"));
}
) -> Result<ServiceTokenClaims, ServiceTokenError> {
let jwt = JwtParts::parse(token)?;
let header_bytes = URL_SAFE_NO_PAD
.decode(parts[0])
.map_err(|e| anyhow!("Base64 decode of header failed: {}", e))?;
.decode(jwt.header)
.map_err(ServiceTokenError::Base64Decode)?;
let header: TokenHeader = serde_json::from_slice(&header_bytes)
.map_err(|e| anyhow!("JSON decode of header failed: {}", e))?;
let header: TokenHeader =
serde_json::from_slice(&header_bytes).map_err(ServiceTokenError::JsonDecode)?;
if header.alg != "ES256K" {
return Err(anyhow!("Unsupported algorithm: {}", header.alg));
if header.alg != super::SigningAlgorithm::ES256K {
return Err(ServiceTokenError::UnsupportedAlgorithm(header.alg));
}
let claims_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|e| anyhow!("Base64 decode of claims failed: {}", e))?;
.decode(jwt.claims)
.map_err(ServiceTokenError::Base64Decode)?;
let claims: ServiceTokenClaims = serde_json::from_slice(&claims_bytes)
.map_err(|e| anyhow!("JSON decode of claims failed: {}", e))?;
let claims: ServiceTokenClaims =
serde_json::from_slice(&claims_bytes).map_err(ServiceTokenError::JsonDecode)?;
let now = Utc::now().timestamp() as usize;
let now = Utc::now().timestamp();
if claims.exp < now {
return Err(anyhow!("Token expired"));
return Err(ServiceTokenError::Expired);
}
if claims.aud.as_str() != self.pds_did {
return Err(anyhow!(
"Invalid audience: expected {}, got {}",
self.pds_did,
claims.aud
));
if claims.aud != self.pds_did {
return Err(ServiceTokenError::InvalidAudience {
expected: self.pds_did.clone(),
actual: claims.aud.clone(),
});
}
if let Some(required) = required_lxm {
match &claims.lxm {
Some(lxm) if lxm == "*" || lxm == required => {}
Some(lxm) if crate::auth::lxm_permits(lxm, required) => {}
Some(lxm) => {
return Err(anyhow!(
"Token lxm '{}' does not permit '{}'",
lxm,
required
));
return Err(ServiceTokenError::LxmMismatch {
token_lxm: lxm.clone(),
required: required.to_string(),
});
}
None => {
return Err(anyhow!("Token missing lxm claim"));
return Err(ServiceTokenError::MissingLxm);
}
}
}
@@ -159,51 +225,51 @@ impl ServiceTokenVerifier {
let public_key = self.resolve_signing_key(did).await?;
let signature_bytes = URL_SAFE_NO_PAD
.decode(parts[2])
.map_err(|e| anyhow!("Base64 decode of signature failed: {}", e))?;
.decode(jwt.signature)
.map_err(ServiceTokenError::Base64Decode)?;
let signature = Signature::from_slice(&signature_bytes)
.map_err(|e| anyhow!("Invalid signature format: {}", e))?;
let signature =
Signature::from_slice(&signature_bytes).map_err(ServiceTokenError::InvalidSignature)?;
let message = format!("{}.{}", parts[0], parts[1]);
let message = jwt.signing_input();
public_key
.verify(message.as_bytes(), &signature)
.map_err(|e| anyhow!("Signature verification failed: {}", e))?;
.map_err(ServiceTokenError::SignatureVerificationFailed)?;
debug!("Service token verified for DID: {}", did);
Ok(claims)
}
async fn resolve_signing_key(&self, did: &str) -> Result<VerifyingKey> {
async fn resolve_signing_key(&self, did: &str) -> Result<VerifyingKey, ServiceTokenError> {
let did_doc = self.resolve_did_document(did).await?;
let atproto_key = did_doc
.verification_method
.iter()
.find(|vm| vm.id.ends_with("#atproto") || vm.id == format!("{}#atproto", did))
.ok_or_else(|| anyhow!("No atproto verification method found in DID document"))?;
.ok_or(ServiceTokenError::NoVerificationMethod)?;
let multibase = atproto_key
.public_key_multibase
.as_ref()
.ok_or_else(|| anyhow!("Verification method missing publicKeyMultibase"))?;
.ok_or(ServiceTokenError::MissingPublicKey)?;
parse_did_key_multibase(multibase)
}
async fn resolve_did_document(&self, did: &str) -> Result<FullDidDocument> {
async fn resolve_did_document(&self, did: &str) -> Result<FullDidDocument, ServiceTokenError> {
if did.starts_with("did:plc:") {
self.resolve_did_plc(did).await
} else if did.starts_with("did:web:") {
self.resolve_did_web(did).await
} else {
Err(anyhow!("Unsupported DID method: {}", did))
Err(ServiceTokenError::UnsupportedDidMethod)
}
}
async fn resolve_did_plc(&self, did: &str) -> Result<FullDidDocument> {
async fn resolve_did_plc(&self, did: &str) -> Result<FullDidDocument, ServiceTokenError> {
let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
debug!("Resolving did:plc {} via {}", did, url);
@@ -212,32 +278,32 @@ impl ServiceTokenVerifier {
.get(&url)
.send()
.await
.map_err(|e| anyhow!("HTTP request failed: {}", e))?;
.map_err(ServiceTokenError::HttpFailed)?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(anyhow!("DID not found: {}", did));
return Err(ServiceTokenError::DidNotFound(did.to_string()));
}
if !resp.status().is_success() {
return Err(anyhow!("HTTP {}", resp.status()));
return Err(ServiceTokenError::HttpStatus(resp.status()));
}
resp.json::<FullDidDocument>()
.await
.map_err(|e| anyhow!("Failed to parse DID document: {}", e))
.map_err(ServiceTokenError::InvalidDidDocument)
}
async fn resolve_did_web(&self, did: &str) -> Result<FullDidDocument> {
async fn resolve_did_web(&self, did: &str) -> Result<FullDidDocument, ServiceTokenError> {
let host = did
.strip_prefix("did:web:")
.ok_or_else(|| anyhow!("Invalid did:web format"))?;
.ok_or(ServiceTokenError::InvalidFormat)?;
let parts: Vec<&str> = host.split(':').collect();
if parts.is_empty() {
return Err(anyhow!("Invalid did:web format - no host"));
}
let host_part = parts[0].replace("%3A", ":");
let mut host_parts = host.splitn(2, ':');
let host_part = host_parts
.next()
.ok_or(ServiceTokenError::InvalidFormat)?
.replace("%3A", ":");
let path_part = host_parts.next();
let scheme = if host_part.starts_with("localhost")
|| host_part.starts_with("127.0.0.1")
@@ -248,11 +314,12 @@ impl ServiceTokenVerifier {
"https"
};
let url = if parts.len() == 1 {
format!("{}://{}/.well-known/did.json", scheme, host_part)
} else {
let path = parts[1..].join("/");
format!("{}://{}/{}/did.json", scheme, host_part, path)
let url = match path_part {
None => format!("{}://{}/.well-known/did.json", scheme, host_part),
Some(path) => {
let resolved_path = path.replace(':', "/");
format!("{}://{}/{}/did.json", scheme, host_part, resolved_path)
}
};
debug!("Resolving did:web {} via {}", did, url);
@@ -262,15 +329,15 @@ impl ServiceTokenVerifier {
.get(&url)
.send()
.await
.map_err(|e| anyhow!("HTTP request failed: {}", e))?;
.map_err(ServiceTokenError::HttpFailed)?;
if !resp.status().is_success() {
return Err(anyhow!("HTTP {}", resp.status()));
return Err(ServiceTokenError::HttpStatus(resp.status()));
}
resp.json::<FullDidDocument>()
.await
.map_err(|e| anyhow!("Failed to parse DID document: {}", e))
.map_err(ServiceTokenError::InvalidDidDocument)
}
}
@@ -280,44 +347,35 @@ impl Default for ServiceTokenVerifier {
}
}
fn parse_did_key_multibase(multibase: &str) -> Result<VerifyingKey> {
fn parse_did_key_multibase(multibase: &str) -> Result<VerifyingKey, ServiceTokenError> {
if !multibase.starts_with('z') {
return Err(anyhow!(
"Expected base58btc multibase encoding (starts with 'z')"
let base_char = multibase.chars().next().unwrap_or('?');
return Err(ServiceTokenError::InvalidMultibase(
multibase::Error::UnknownBase(base_char),
));
}
let (_, decoded) =
multibase::decode(multibase).map_err(|e| anyhow!("Failed to decode multibase: {}", e))?;
let (_, decoded) = multibase::decode(multibase).map_err(ServiceTokenError::InvalidMultibase)?;
if decoded.len() < 2 {
return Err(anyhow!("Invalid multicodec data"));
return Err(ServiceTokenError::InvalidMulticodec);
}
let (codec, key_bytes) = if decoded[0] == 0xe7 && decoded[1] == 0x01 {
(0xe701u16, &decoded[2..])
let key_bytes = if decoded.starts_with(&crate::plc::SECP256K1_MULTICODEC_PREFIX) {
&decoded[crate::plc::SECP256K1_MULTICODEC_PREFIX.len()..]
} else {
return Err(anyhow!(
"Unsupported key type. Expected secp256k1 (0xe701), got {:02x}{:02x}",
decoded[0],
decoded[1]
));
return Err(ServiceTokenError::UnsupportedKeyType);
};
if codec != 0xe701 {
return Err(anyhow!("Only secp256k1 keys are supported"));
}
VerifyingKey::from_sec1_bytes(key_bytes).map_err(|e| anyhow!("Invalid public key: {}", e))
VerifyingKey::from_sec1_bytes(key_bytes).map_err(ServiceTokenError::InvalidPublicKey)
}
pub fn is_service_token(token: &str) -> bool {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
let Ok(jwt) = JwtParts::parse(token) else {
return false;
}
};
let Ok(claims_bytes) = URL_SAFE_NO_PAD.decode(parts[1]) else {
let Ok(claims_bytes) = URL_SAFE_NO_PAD.decode(jwt.claims) else {
return false;
};
@@ -377,4 +435,34 @@ mod tests {
let result = parse_did_key_multibase(test_key);
assert!(result.is_ok(), "Failed to parse valid multibase key");
}
#[test]
fn test_jwt_parts_parse_valid() {
let jwt = JwtParts::parse("a.b.c").unwrap();
assert_eq!(jwt.header, "a");
assert_eq!(jwt.claims, "b");
assert_eq!(jwt.signature, "c");
}
#[test]
fn test_jwt_parts_parse_too_few() {
assert!(matches!(
JwtParts::parse("a.b"),
Err(ServiceTokenError::InvalidFormat)
));
}
#[test]
fn test_jwt_parts_parse_too_many() {
assert!(matches!(
JwtParts::parse("a.b.c.d"),
Err(ServiceTokenError::InvalidFormat)
));
}
#[test]
fn test_jwt_parts_signing_input() {
let jwt = JwtParts::parse("header.claims.sig").unwrap();
assert_eq!(jwt.signing_input(), "header.claims");
}
}
@@ -1,6 +1,8 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::Mac;
use sha2::{Digest, Sha256};
use tranquil_db_traits::CommsChannel;
use tranquil_types::Did;
type HmacSha256 = hmac::Hmac<Sha256>;
@@ -9,7 +11,8 @@ const DEFAULT_SIGNUP_EXPIRY_MINUTES: u64 = 30;
const DEFAULT_MIGRATION_EXPIRY_HOURS: u64 = 48;
const DEFAULT_CHANNEL_UPDATE_EXPIRY_MINUTES: u64 = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum VerificationPurpose {
Signup,
Migration,
@@ -25,15 +28,6 @@ impl VerificationPurpose {
}
}
fn from_str(s: &str) -> Option<Self> {
match s {
"signup" => Some(Self::Signup),
"migration" => Some(Self::Migration),
"channel_update" => Some(Self::ChannelUpdate),
_ => None,
}
}
fn default_expiry_seconds(&self) -> u64 {
match self {
Self::Signup => DEFAULT_SIGNUP_EXPIRY_MINUTES * 60,
@@ -43,11 +37,24 @@ impl VerificationPurpose {
}
}
impl std::str::FromStr for VerificationPurpose {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"signup" => Ok(Self::Signup),
"migration" => Ok(Self::Migration),
"channel_update" => Ok(Self::ChannelUpdate),
_ => Err(()),
}
}
}
#[derive(Debug)]
pub struct VerificationToken {
pub did: String,
pub did: Did,
pub purpose: VerificationPurpose,
pub channel: String,
pub channel: CommsChannel,
pub identifier_hash: String,
pub expires_at: u64,
}
@@ -75,22 +82,27 @@ pub fn hash_identifier(identifier: &str) -> String {
URL_SAFE_NO_PAD.encode(&result[..16])
}
pub fn generate_signup_token(did: &str, channel: &str, identifier: &str) -> String {
pub fn generate_signup_token(did: &Did, channel: CommsChannel, identifier: &str) -> String {
generate_token(did, VerificationPurpose::Signup, channel, identifier)
}
pub fn generate_migration_token(did: &str, email: &str) -> String {
generate_token(did, VerificationPurpose::Migration, "email", email)
pub fn generate_migration_token(did: &Did, email: &str) -> String {
generate_token(
did,
VerificationPurpose::Migration,
CommsChannel::Email,
email,
)
}
pub fn generate_channel_update_token(did: &str, channel: &str, identifier: &str) -> String {
pub fn generate_channel_update_token(did: &Did, channel: CommsChannel, identifier: &str) -> String {
generate_token(did, VerificationPurpose::ChannelUpdate, channel, identifier)
}
pub fn generate_token(
did: &str,
did: &Did,
purpose: VerificationPurpose,
channel: &str,
channel: CommsChannel,
identifier: &str,
) -> String {
generate_token_with_expiry(
@@ -103,14 +115,15 @@ pub fn generate_token(
}
pub fn generate_token_with_expiry(
did: &str,
did: &Did,
purpose: VerificationPurpose,
channel: &str,
channel: CommsChannel,
identifier: &str,
expiry_seconds: u64,
) -> String {
let key = derive_verification_key();
let identifier_hash = hash_identifier(identifier);
let channel_str = channel.as_str();
let expires_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
@@ -121,7 +134,7 @@ pub fn generate_token_with_expiry(
"{}|{}|{}|{}|{}",
did,
purpose.as_str(),
channel,
channel_str,
identifier_hash,
expires_at
);
@@ -135,7 +148,7 @@ pub fn generate_token_with_expiry(
TOKEN_VERSION,
did,
purpose.as_str(),
channel,
channel_str,
identifier_hash,
expires_at,
signature
@@ -170,7 +183,7 @@ impl std::fmt::Display for VerifyError {
pub fn verify_signup_token(
token: &str,
expected_channel: &str,
expected_channel: CommsChannel,
expected_identifier: &str,
) -> Result<VerificationToken, VerifyError> {
let parsed = verify_token_signature(token)?;
@@ -195,7 +208,7 @@ pub fn verify_migration_token(
if parsed.purpose != VerificationPurpose::Migration {
return Err(VerifyError::PurposeMismatch);
}
if parsed.channel != "email" {
if parsed.channel != CommsChannel::Email {
return Err(VerifyError::ChannelMismatch);
}
let expected_hash = hash_identifier(expected_email);
@@ -207,7 +220,7 @@ pub fn verify_migration_token(
pub fn verify_channel_update_token(
token: &str,
expected_channel: &str,
expected_channel: CommsChannel,
expected_identifier: &str,
) -> Result<VerificationToken, VerifyError> {
let parsed = verify_token_signature(token)?;
@@ -226,10 +239,10 @@ pub fn verify_channel_update_token(
pub fn verify_token_for_did(
token: &str,
expected_did: &str,
expected_did: &Did,
) -> Result<VerificationToken, VerifyError> {
let parsed = verify_token_signature(token)?;
if parsed.did != expected_did {
if parsed.did != *expected_did {
return Err(VerifyError::IdentifierMismatch);
}
Ok(parsed)
@@ -253,12 +266,17 @@ pub fn verify_token_signature(token: &str) -> Result<VerificationToken, VerifyEr
let did = parts[1];
let purpose_str = parts[2];
let channel = parts[3];
let channel_str = parts[3];
let identifier_hash = parts[4];
let expires_at: u64 = parts[5].parse().map_err(|_| VerifyError::InvalidFormat)?;
let provided_signature = parts[6];
let purpose = VerificationPurpose::from_str(purpose_str).ok_or(VerifyError::InvalidFormat)?;
let purpose: VerificationPurpose = purpose_str
.parse()
.map_err(|_| VerifyError::InvalidFormat)?;
let channel: CommsChannel = channel_str
.parse()
.map_err(|_| VerifyError::InvalidFormat)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -271,7 +289,7 @@ pub fn verify_token_signature(token: &str) -> Result<VerificationToken, VerifyEr
let key = derive_verification_key();
let payload = format!(
"{}|{}|{}|{}|{}",
did, purpose_str, channel, identifier_hash, expires_at
did, purpose_str, channel_str, identifier_hash, expires_at
);
let mut mac = <HmacSha256 as Mac>::new_from_slice(&key).expect("HMAC key size is valid");
mac.update(payload.as_bytes());
@@ -286,10 +304,12 @@ pub fn verify_token_signature(token: &str) -> Result<VerificationToken, VerifyEr
return Err(VerifyError::InvalidSignature);
}
let parsed_did: Did = did.parse().map_err(|_| VerifyError::InvalidFormat)?;
Ok(VerificationToken {
did: did.to_string(),
did: parsed_did,
purpose,
channel: channel.to_string(),
channel,
identifier_hash: identifier_hash.to_string(),
expires_at,
})
@@ -309,10 +329,10 @@ mod tests {
#[test]
fn test_signup_token() {
let did = "did:plc:test123";
let channel = "email";
let did: Did = "did:plc:test123".parse().unwrap();
let channel = CommsChannel::Email;
let identifier = "test@example.com";
let token = generate_signup_token(did, channel, identifier);
let token = generate_signup_token(&did, channel, identifier);
let result = verify_signup_token(&token, channel, identifier);
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
let parsed = result.unwrap();
@@ -323,9 +343,9 @@ mod tests {
#[test]
fn test_migration_token() {
let did = "did:plc:test123";
let did: Did = "did:plc:test123".parse().unwrap();
let email = "test@example.com";
let token = generate_migration_token(did, email);
let token = generate_migration_token(&did, email);
let result = verify_migration_token(&token, email);
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
let parsed = result.unwrap();
@@ -335,64 +355,64 @@ mod tests {
#[test]
fn test_token_case_insensitive() {
let did = "did:plc:test123";
let token = generate_signup_token(did, "email", "Test@Example.COM");
let result = verify_signup_token(&token, "email", "test@example.com");
let did: Did = "did:plc:test123".parse().unwrap();
let token = generate_signup_token(&did, CommsChannel::Email, "Test@Example.COM");
let result = verify_signup_token(&token, CommsChannel::Email, "test@example.com");
assert!(result.is_ok());
}
#[test]
fn test_token_wrong_identifier() {
let did = "did:plc:test123";
let token = generate_signup_token(did, "email", "test@example.com");
let result = verify_signup_token(&token, "email", "other@example.com");
let did: Did = "did:plc:test123".parse().unwrap();
let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com");
let result = verify_signup_token(&token, CommsChannel::Email, "other@example.com");
assert!(matches!(result, Err(VerifyError::IdentifierMismatch)));
}
#[test]
fn test_token_wrong_channel() {
let did = "did:plc:test123";
let token = generate_signup_token(did, "email", "test@example.com");
let result = verify_signup_token(&token, "discord", "test@example.com");
let did: Did = "did:plc:test123".parse().unwrap();
let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com");
let result = verify_signup_token(&token, CommsChannel::Discord, "test@example.com");
assert!(matches!(result, Err(VerifyError::ChannelMismatch)));
}
#[test]
fn test_expired_token() {
let did = "did:plc:test123";
let did: Did = "did:plc:test123".parse().unwrap();
let token = generate_token_with_expiry(
did,
&did,
VerificationPurpose::Signup,
"email",
CommsChannel::Email,
"test@example.com",
0,
);
std::thread::sleep(std::time::Duration::from_millis(1100));
let result = verify_signup_token(&token, "email", "test@example.com");
let result = verify_signup_token(&token, CommsChannel::Email, "test@example.com");
assert!(matches!(result, Err(VerifyError::Expired)));
}
#[test]
fn test_invalid_token() {
let result = verify_signup_token("invalid-token", "email", "test@example.com");
let result = verify_signup_token("invalid-token", CommsChannel::Email, "test@example.com");
assert!(matches!(result, Err(VerifyError::InvalidFormat)));
}
#[test]
fn test_purpose_mismatch() {
let did = "did:plc:test123";
let did: Did = "did:plc:test123".parse().unwrap();
let email = "test@example.com";
let signup_token = generate_signup_token(did, "email", email);
let signup_token = generate_signup_token(&did, CommsChannel::Email, email);
let result = verify_migration_token(&signup_token, email);
assert!(matches!(result, Err(VerifyError::PurposeMismatch)));
}
#[test]
fn test_discord_channel() {
let did = "did:plc:test123";
let did: Did = "did:plc:test123".parse().unwrap();
let discord_id = "123456789012345678";
let token = generate_signup_token(did, "discord", discord_id);
let result = verify_signup_token(&token, "discord", discord_id);
let token = generate_signup_token(&did, CommsChannel::Discord, discord_id);
let result = verify_signup_token(&token, CommsChannel::Discord, discord_id);
assert!(result.is_ok());
}
+24 -12
View File
@@ -1,24 +1,36 @@
use uuid::Uuid;
use webauthn_rs::prelude::*;
#[derive(Debug, thiserror::Error)]
pub enum WebauthnError {
#[error("Invalid origin URL: {0}")]
InvalidOrigin(String),
#[error("Failed to create WebAuthn builder: {0}")]
BuilderFailed(String),
#[error("Registration failed: {0}")]
RegistrationFailed(String),
#[error("Authentication failed: {0}")]
AuthenticationFailed(String),
}
pub struct WebAuthnConfig {
webauthn: Webauthn,
}
impl WebAuthnConfig {
pub fn new(hostname: &str) -> Result<Self, String> {
pub fn new(hostname: &str) -> Result<Self, WebauthnError> {
let rp_id = hostname.split(':').next().unwrap_or(hostname).to_string();
let rp_origin = Url::parse(&format!("https://{}", hostname))
.map_err(|e| format!("Invalid origin URL: {}", e))?;
.map_err(|e| WebauthnError::InvalidOrigin(e.to_string()))?;
let builder = WebauthnBuilder::new(&rp_id, &rp_origin)
.map_err(|e| format!("Failed to create WebAuthn builder: {}", e))?
.map_err(|e| WebauthnError::BuilderFailed(e.to_string()))?
.rp_name("Tranquil PDS")
.danger_set_user_presence_only_security_keys(true);
let webauthn = builder
.build()
.map_err(|e| format!("Failed to build WebAuthn: {}", e))?;
.map_err(|e| WebauthnError::BuilderFailed(e.to_string()))?;
Ok(Self { webauthn })
}
@@ -29,7 +41,7 @@ impl WebAuthnConfig {
username: &str,
display_name: &str,
exclude_credentials: Vec<CredentialID>,
) -> Result<(CreationChallengeResponse, SecurityKeyRegistration), String> {
) -> Result<(CreationChallengeResponse, SecurityKeyRegistration), WebauthnError> {
let user_unique_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, user_id.as_bytes());
self.webauthn
@@ -45,35 +57,35 @@ impl WebAuthnConfig {
None,
None,
)
.map_err(|e| format!("Failed to start registration: {}", e))
.map_err(|e| WebauthnError::RegistrationFailed(e.to_string()))
}
pub fn finish_registration(
&self,
reg: &RegisterPublicKeyCredential,
state: &SecurityKeyRegistration,
) -> Result<SecurityKey, String> {
) -> Result<SecurityKey, WebauthnError> {
self.webauthn
.finish_securitykey_registration(reg, state)
.map_err(|e| format!("Failed to finish registration: {}", e))
.map_err(|e| WebauthnError::RegistrationFailed(e.to_string()))
}
pub fn start_authentication(
&self,
credentials: Vec<SecurityKey>,
) -> Result<(RequestChallengeResponse, SecurityKeyAuthentication), String> {
) -> Result<(RequestChallengeResponse, SecurityKeyAuthentication), WebauthnError> {
self.webauthn
.start_securitykey_authentication(&credentials)
.map_err(|e| format!("Failed to start authentication: {}", e))
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
}
pub fn finish_authentication(
&self,
auth: &PublicKeyCredential,
state: &SecurityKeyAuthentication,
) -> Result<AuthenticationResult, String> {
) -> Result<AuthenticationResult, WebauthnError> {
self.webauthn
.finish_securitykey_authentication(auth, state)
.map_err(|e| format!("Failed to finish authentication: {}", e))
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
}
}
+35
View File
@@ -0,0 +1,35 @@
pub fn session_key(did: &str, jti: &str) -> String {
format!("auth:session:{}:{}", did, jti)
}
pub fn signing_key_key(did: &str) -> String {
format!("auth:key:{}", did)
}
pub fn user_status_key(did: &str) -> String {
format!("auth:status:{}", did)
}
pub fn handle_key(handle: &str) -> String {
format!("handle:{}", handle)
}
pub fn reauth_key(did: &str) -> String {
format!("reauth:{}", did)
}
pub fn plc_doc_key(did: &str) -> String {
format!("plc:doc:{}", did)
}
pub fn plc_data_key(did: &str) -> String {
format!("plc:data:{}", did)
}
pub fn email_update_key(did: &str) -> String {
format!("email_update:{}", did)
}
pub fn scope_ref_key(cid: &str) -> String {
format!("scope_ref:{}", cid)
}
+35 -19
View File
@@ -1,3 +1,4 @@
use std::num::{NonZeroU32, NonZeroU64};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::Duration;
@@ -24,15 +25,15 @@ pub struct CircuitBreaker {
impl CircuitBreaker {
pub fn new(
name: &str,
failure_threshold: u32,
success_threshold: u32,
timeout_secs: u64,
failure_threshold: NonZeroU32,
success_threshold: NonZeroU32,
timeout_secs: NonZeroU64,
) -> Self {
Self {
name: name.to_string(),
failure_threshold,
success_threshold,
timeout: Duration::from_secs(timeout_secs),
failure_threshold: failure_threshold.get(),
success_threshold: success_threshold.get(),
timeout: Duration::from_secs(timeout_secs.get()),
state: Arc::new(RwLock::new(CircuitState::Closed)),
failure_count: AtomicU32::new(0),
success_count: AtomicU32::new(0),
@@ -49,10 +50,10 @@ impl CircuitBreaker {
let last_failure = self.last_failure_time.load(Ordering::SeqCst);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_secs();
if now - last_failure >= self.timeout.as_secs() {
if now.saturating_sub(last_failure) >= self.timeout.as_secs() {
drop(state);
let mut state = self.state.write().await;
if *state == CircuitState::Open {
@@ -100,7 +101,7 @@ impl CircuitBreaker {
*state = CircuitState::Open;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_secs();
self.last_failure_time.store(now, Ordering::SeqCst);
tracing::warn!(
@@ -150,8 +151,18 @@ impl Default for CircuitBreakers {
impl CircuitBreakers {
pub fn new() -> Self {
Self {
plc_directory: Arc::new(CircuitBreaker::new("plc_directory", 5, 3, 60)),
relay_notification: Arc::new(CircuitBreaker::new("relay_notification", 10, 5, 30)),
plc_directory: Arc::new(CircuitBreaker::new(
"plc_directory",
const { NonZeroU32::new(5).unwrap() },
const { NonZeroU32::new(3).unwrap() },
const { NonZeroU64::new(60).unwrap() },
)),
relay_notification: Arc::new(CircuitBreaker::new(
"relay_notification",
const { NonZeroU32::new(10).unwrap() },
const { NonZeroU32::new(5).unwrap() },
const { NonZeroU64::new(30).unwrap() },
)),
}
}
}
@@ -223,16 +234,21 @@ impl<E: std::error::Error + 'static> std::error::Error for CircuitBreakerError<E
mod tests {
use super::*;
const TEST_FAILURE: NonZeroU32 = const { NonZeroU32::new(3).unwrap() };
const TEST_SUCCESS: NonZeroU32 = const { NonZeroU32::new(2).unwrap() };
const TEST_TIMEOUT: NonZeroU64 = const { NonZeroU64::new(10).unwrap() };
const TEST_ZERO_TIMEOUT: NonZeroU64 = const { NonZeroU64::new(1).unwrap() };
#[tokio::test]
async fn test_circuit_breaker_starts_closed() {
let cb = CircuitBreaker::new("test", 3, 2, 10);
let cb = CircuitBreaker::new("test", TEST_FAILURE, TEST_SUCCESS, TEST_TIMEOUT);
assert_eq!(cb.state().await, CircuitState::Closed);
assert!(cb.can_execute().await);
}
#[tokio::test]
async fn test_circuit_breaker_opens_after_failures() {
let cb = CircuitBreaker::new("test", 3, 2, 10);
let cb = CircuitBreaker::new("test", TEST_FAILURE, TEST_SUCCESS, TEST_TIMEOUT);
cb.record_failure().await;
assert_eq!(cb.state().await, CircuitState::Closed);
@@ -247,7 +263,7 @@ mod tests {
#[tokio::test]
async fn test_circuit_breaker_success_resets_failures() {
let cb = CircuitBreaker::new("test", 3, 2, 10);
let cb = CircuitBreaker::new("test", TEST_FAILURE, TEST_SUCCESS, TEST_TIMEOUT);
cb.record_failure().await;
cb.record_failure().await;
@@ -263,12 +279,12 @@ mod tests {
#[tokio::test]
async fn test_circuit_breaker_half_open_closes_after_successes() {
let cb = CircuitBreaker::new("test", 3, 2, 0);
let cb = CircuitBreaker::new("test", TEST_FAILURE, TEST_SUCCESS, TEST_ZERO_TIMEOUT);
futures::future::join_all((0..3).map(|_| cb.record_failure())).await;
assert_eq!(cb.state().await, CircuitState::Open);
tokio::time::sleep(Duration::from_millis(100)).await;
tokio::time::sleep(Duration::from_millis(1100)).await;
assert!(cb.can_execute().await);
assert_eq!(cb.state().await, CircuitState::HalfOpen);
@@ -281,11 +297,11 @@ mod tests {
#[tokio::test]
async fn test_circuit_breaker_half_open_reopens_on_failure() {
let cb = CircuitBreaker::new("test", 3, 2, 0);
let cb = CircuitBreaker::new("test", TEST_FAILURE, TEST_SUCCESS, TEST_ZERO_TIMEOUT);
futures::future::join_all((0..3).map(|_| cb.record_failure())).await;
tokio::time::sleep(Duration::from_millis(100)).await;
tokio::time::sleep(Duration::from_millis(1100)).await;
cb.can_execute().await;
cb.record_failure().await;
@@ -294,7 +310,7 @@ mod tests {
#[tokio::test]
async fn test_with_circuit_breaker_helper() {
let cb = CircuitBreaker::new("test", 3, 2, 10);
let cb = CircuitBreaker::new("test", TEST_FAILURE, TEST_SUCCESS, TEST_TIMEOUT);
let result: Result<i32, CircuitBreakerError<std::io::Error>> =
with_circuit_breaker(&cb, || async { Ok(42) }).await;
+1 -1
View File
@@ -7,4 +7,4 @@ pub use tranquil_comms::{
mime_encode_header, sanitize_header_value, validate_locale,
};
pub use service::{CommsService, channel_display_name, repo as comms_repo};
pub use service::{CommsService, repo as comms_repo};
+22 -123
View File
@@ -7,8 +7,7 @@ use tokio::time::interval;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use tranquil_comms::{
CommsChannel, CommsSender, CommsStatus, CommsType, NewComms, SendError, format_message,
get_strings,
CommsChannel, CommsSender, CommsType, NewComms, SendError, format_message, get_strings,
};
use tranquil_db_traits::{InfraRepository, QueuedComms, UserCommsPrefs, UserRepository};
use uuid::Uuid;
@@ -54,35 +53,12 @@ impl CommsService {
}
pub async fn enqueue(&self, item: NewComms) -> Result<Uuid, tranquil_db_traits::DbError> {
let channel = match item.channel {
CommsChannel::Email => tranquil_db_traits::CommsChannel::Email,
CommsChannel::Discord => tranquil_db_traits::CommsChannel::Discord,
CommsChannel::Telegram => tranquil_db_traits::CommsChannel::Telegram,
CommsChannel::Signal => tranquil_db_traits::CommsChannel::Signal,
};
let comms_type = match item.comms_type {
CommsType::Welcome => tranquil_db_traits::CommsType::Welcome,
CommsType::EmailVerification => tranquil_db_traits::CommsType::EmailVerification,
CommsType::PasswordReset => tranquil_db_traits::CommsType::PasswordReset,
CommsType::EmailUpdate => tranquil_db_traits::CommsType::EmailUpdate,
CommsType::AccountDeletion => tranquil_db_traits::CommsType::AccountDeletion,
CommsType::AdminEmail => tranquil_db_traits::CommsType::AdminEmail,
CommsType::PlcOperation => tranquil_db_traits::CommsType::PlcOperation,
CommsType::TwoFactorCode => tranquil_db_traits::CommsType::TwoFactorCode,
CommsType::PasskeyRecovery => tranquil_db_traits::CommsType::PasskeyRecovery,
CommsType::LegacyLoginAlert => tranquil_db_traits::CommsType::LegacyLoginAlert,
CommsType::MigrationVerification => {
tranquil_db_traits::CommsType::MigrationVerification
}
CommsType::ChannelVerification => tranquil_db_traits::CommsType::ChannelVerification,
CommsType::ChannelVerified => tranquil_db_traits::CommsType::ChannelVerified,
};
let id = self
.infra_repo
.enqueue_comms(
Some(item.user_id),
channel,
comms_type,
item.channel,
item.comms_type,
&item.recipient,
item.subject.as_deref(),
&item.body,
@@ -144,62 +120,15 @@ impl CommsService {
async fn process_item(&self, item: QueuedComms) {
let comms_id = item.id;
let channel = match item.channel {
tranquil_db_traits::CommsChannel::Email => CommsChannel::Email,
tranquil_db_traits::CommsChannel::Discord => CommsChannel::Discord,
tranquil_db_traits::CommsChannel::Telegram => CommsChannel::Telegram,
tranquil_db_traits::CommsChannel::Signal => CommsChannel::Signal,
};
let comms_item = tranquil_comms::QueuedComms {
id: item.id,
user_id: item.user_id,
channel,
comms_type: match item.comms_type {
tranquil_db_traits::CommsType::Welcome => CommsType::Welcome,
tranquil_db_traits::CommsType::EmailVerification => CommsType::EmailVerification,
tranquil_db_traits::CommsType::PasswordReset => CommsType::PasswordReset,
tranquil_db_traits::CommsType::EmailUpdate => CommsType::EmailUpdate,
tranquil_db_traits::CommsType::AccountDeletion => CommsType::AccountDeletion,
tranquil_db_traits::CommsType::AdminEmail => CommsType::AdminEmail,
tranquil_db_traits::CommsType::PlcOperation => CommsType::PlcOperation,
tranquil_db_traits::CommsType::TwoFactorCode => CommsType::TwoFactorCode,
tranquil_db_traits::CommsType::PasskeyRecovery => CommsType::PasskeyRecovery,
tranquil_db_traits::CommsType::LegacyLoginAlert => CommsType::LegacyLoginAlert,
tranquil_db_traits::CommsType::MigrationVerification => {
CommsType::MigrationVerification
}
tranquil_db_traits::CommsType::ChannelVerification => {
CommsType::ChannelVerification
}
tranquil_db_traits::CommsType::ChannelVerified => CommsType::ChannelVerified,
},
status: match item.status {
tranquil_db_traits::CommsStatus::Pending => CommsStatus::Pending,
tranquil_db_traits::CommsStatus::Processing => CommsStatus::Processing,
tranquil_db_traits::CommsStatus::Sent => CommsStatus::Sent,
tranquil_db_traits::CommsStatus::Failed => CommsStatus::Failed,
},
recipient: item.recipient,
subject: item.subject,
body: item.body,
metadata: item.metadata,
attempts: item.attempts,
max_attempts: item.max_attempts,
last_error: item.last_error,
created_at: item.created_at,
updated_at: item.updated_at,
scheduled_for: item.scheduled_for,
processed_at: item.processed_at,
};
let result = match self.senders.get(&channel) {
Some(sender) => sender.send(&comms_item).await,
let result = match self.senders.get(&item.channel) {
Some(sender) => sender.send(&item).await,
None => {
warn!(
comms_id = %comms_id,
channel = ?channel,
channel = ?item.channel,
"No sender registered for channel"
);
Err(SendError::NotConfigured(channel))
Err(SendError::NotConfigured(item.channel))
}
};
match result {
@@ -240,15 +169,6 @@ impl CommsService {
}
}
pub fn channel_display_name(channel: CommsChannel) -> &'static str {
match channel {
CommsChannel::Email => "email",
CommsChannel::Discord => "Discord",
CommsChannel::Telegram => "Telegram",
CommsChannel::Signal => "Signal",
}
}
struct ResolvedRecipient {
channel: tranquil_db_traits::CommsChannel,
recipient: String,
@@ -292,15 +212,6 @@ fn resolve_recipient(
}
}
fn channel_from_str(s: &str) -> tranquil_db_traits::CommsChannel {
match s {
"discord" => tranquil_db_traits::CommsChannel::Discord,
"telegram" => tranquil_db_traits::CommsChannel::Telegram,
"signal" => tranquil_db_traits::CommsChannel::Signal,
_ => tranquil_db_traits::CommsChannel::Email,
}
}
pub mod repo {
use super::*;
use tranquil_db_traits::DbError;
@@ -453,7 +364,6 @@ pub mod repo {
infra_repo: &dyn InfraRepository,
user_id: Uuid,
token: &str,
purpose: &str,
hostname: &str,
) -> Result<Uuid, DbError> {
let prefs = user_repo
@@ -463,18 +373,9 @@ pub mod repo {
let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en"));
let current_email = prefs.email.clone().unwrap_or_default();
let (subject_template, body_template, comms_type) = match purpose {
"email_update" => (
strings.email_update_subject,
strings.short_token_body,
CommsType::EmailUpdate,
),
_ => (
strings.email_update_subject,
strings.short_token_body,
CommsType::EmailUpdate,
),
};
let subject_template = strings.email_update_subject;
let body_template = strings.short_token_body;
let comms_type = CommsType::EmailUpdate;
let verify_page = format!("https://{}/app/settings", hostname);
let body = format_message(
@@ -642,13 +543,19 @@ pub mod repo {
user_repo: &dyn UserRepository,
infra_repo: &dyn InfraRepository,
user_id: Uuid,
channel: &str,
channel: tranquil_db_traits::CommsChannel,
recipient: &str,
code: &str,
hostname: &str,
) -> Result<Uuid, DbError> {
let comms_channel = channel_from_str(channel);
let prefs = user_repo.get_comms_prefs(user_id).await.ok().flatten();
let comms_channel = channel;
let prefs = match user_repo.get_comms_prefs(user_id).await {
Ok(p) => p,
Err(e) => {
tracing::warn!(user_id = %user_id, error = %e, "failed to fetch comms preferences, using defaults");
None
}
};
let locale = prefs
.as_ref()
.and_then(|p| p.preferred_locale.as_deref())
@@ -762,7 +669,7 @@ pub mod repo {
user_repo: &dyn UserRepository,
infra_repo: &dyn InfraRepository,
user_id: Uuid,
channel_name: &str,
channel: tranquil_db_traits::CommsChannel,
recipient: &str,
hostname: &str,
) -> Result<Uuid, DbError> {
@@ -771,27 +678,19 @@ pub mod repo {
.await?
.ok_or(DbError::NotFound)?;
let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en"));
let display_name = match channel_name {
"email" => "Email",
"discord" => "Discord",
"telegram" => "Telegram",
"signal" => "Signal",
other => other,
};
let body = format_message(
strings.channel_verified_body,
&[
("handle", &prefs.handle),
("channel", display_name),
("channel", channel.display_name()),
("hostname", hostname),
],
);
let subject = format_message(strings.channel_verified_subject, &[("hostname", hostname)]);
let comms_channel = channel_from_str(channel_name);
infra_repo
.enqueue_comms(
Some(user_id),
comms_channel,
channel,
CommsType::ChannelVerified,
recipient,
Some(&subject),
+55 -13
View File
@@ -6,6 +6,29 @@ use p256::ecdsa::SigningKey;
use sha2::{Digest, Sha256};
use std::sync::OnceLock;
#[derive(Debug)]
pub enum CryptoError {
CipherCreationFailed(String),
EncryptionFailed(String),
DecryptionFailed(String),
DataTooShort,
UnknownEncryptionVersion(i32),
}
impl std::fmt::Display for CryptoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CipherCreationFailed(e) => write!(f, "Failed to create cipher: {}", e),
Self::EncryptionFailed(e) => write!(f, "Encryption failed: {}", e),
Self::DecryptionFailed(e) => write!(f, "Decryption failed: {}", e),
Self::DataTooShort => write!(f, "Encrypted data too short"),
Self::UnknownEncryptionVersion(v) => write!(f, "Unknown encryption version: {}", v),
}
}
}
impl std::error::Error for CryptoError {}
static CONFIG: OnceLock<AuthConfig> = OnceLock::new();
pub const ENCRYPTION_VERSION: i32 = 1;
@@ -208,11 +231,11 @@ impl AuthConfig {
}
}
pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
use rand::RngCore;
let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key)
.map_err(|e| format!("Failed to create cipher: {}", e))?;
.map_err(|e| CryptoError::CipherCreationFailed(e.to_string()))?;
let mut nonce_bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
@@ -222,7 +245,7 @@ impl AuthConfig {
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| format!("Encryption failed: {}", e))?;
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
let mut result = Vec::with_capacity(12 + ciphertext.len());
result.extend_from_slice(&nonce_bytes);
@@ -231,13 +254,13 @@ impl AuthConfig {
Ok(result)
}
pub fn decrypt_user_key(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
pub fn decrypt_user_key(&self, encrypted: &[u8]) -> Result<Vec<u8>, CryptoError> {
if encrypted.len() < 12 {
return Err("Encrypted data too short".to_string());
return Err(CryptoError::DataTooShort);
}
let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key)
.map_err(|e| format!("Failed to create cipher: {}", e))?;
.map_err(|e| CryptoError::CipherCreationFailed(e.to_string()))?;
#[allow(deprecated)]
let nonce = Nonce::from_slice(&encrypted[..12]);
@@ -245,18 +268,37 @@ impl AuthConfig {
cipher
.decrypt(nonce, ciphertext)
.map_err(|e| format!("Decryption failed: {}", e))
.map_err(|e| CryptoError::DecryptionFailed(e.to_string()))
}
}
pub fn encrypt_key(plaintext: &[u8]) -> Result<Vec<u8>, String> {
pub fn encrypt_key(plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
AuthConfig::get().encrypt_user_key(plaintext)
}
pub fn decrypt_key(encrypted: &[u8], version: Option<i32>) -> Result<Vec<u8>, String> {
match version.unwrap_or(0) {
0 => Ok(encrypted.to_vec()),
1 => AuthConfig::get().decrypt_user_key(encrypted),
v => Err(format!("Unknown encryption version: {}", v)),
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncryptionVersion {
Unencrypted,
AesGcm,
}
impl EncryptionVersion {
pub fn from_db(version: Option<i32>) -> Result<Self, CryptoError> {
match version.unwrap_or(0) {
0 => Ok(Self::Unencrypted),
1 => Ok(Self::AesGcm),
v => Err(CryptoError::UnknownEncryptionVersion(v)),
}
}
pub fn from_db_required(version: i32) -> Result<Self, CryptoError> {
Self::from_db(Some(version))
}
}
pub fn decrypt_key(encrypted: &[u8], version: Option<i32>) -> Result<Vec<u8>, CryptoError> {
match EncryptionVersion::from_db(version)? {
EncryptionVersion::Unencrypted => Ok(encrypted.to_vec()),
EncryptionVersion::AesGcm => AuthConfig::get().decrypt_user_key(encrypted),
}
}
@@ -163,4 +163,16 @@ mod tests {
fn test_validate_scopes_invalid() {
assert!(validate_delegation_scopes("invalid:scope").is_err());
}
#[test]
fn test_scope_presets_parse() {
SCOPE_PRESETS.iter().for_each(|p| {
validate_delegation_scopes(p.scopes).unwrap_or_else(|e| {
panic!(
"preset '{}' has invalid scopes '{}': {}",
p.name, p.scopes, e
)
});
});
}
}
+8 -4
View File
@@ -197,12 +197,16 @@ impl ImageProcessor {
max_size: u32,
) -> Result<ProcessedImage, ImageError> {
let (orig_width, orig_height) = (img.width(), img.height());
let safe_f64_to_u32 =
|v: f64| -> u32 { u32::try_from(v.round() as u64).unwrap_or(u32::MAX) };
let (new_width, new_height) = if orig_width > orig_height {
let ratio = max_size as f64 / orig_width as f64;
(max_size, (orig_height as f64 * ratio) as u32)
let ratio = f64::from(max_size) / f64::from(orig_width);
let scaled = safe_f64_to_u32((f64::from(orig_height) * ratio).max(1.0));
(max_size, scaled.min(max_size))
} else {
let ratio = max_size as f64 / orig_height as f64;
((orig_width as f64 * ratio) as u32, max_size)
let ratio = f64::from(max_size) / f64::from(orig_height);
let scaled = safe_f64_to_u32((f64::from(orig_width) * ratio).max(1.0));
(scaled.min(max_size), max_size)
};
let thumb = img.resize(new_width, new_height, FilterType::Lanczos3);
self.encode_image(&thumb)
+77 -12
View File
@@ -2,6 +2,7 @@ pub mod api;
pub mod appview;
pub mod auth;
pub mod cache;
pub mod cache_keys;
pub mod cid_types;
pub mod circuit_breaker;
pub mod comms;
@@ -658,27 +659,91 @@ pub fn app(state: AppState) -> Router {
.layer(DefaultBodyLimit::max(64 * 1024)),
)
.layer(DefaultBodyLimit::max(util::get_max_blob_size()))
.layer(axum::middleware::map_response(rewrite_422_to_400))
.layer(middleware::from_fn(metrics::metrics_middleware))
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([
"Authorization".parse().unwrap(),
"Content-Type".parse().unwrap(),
"Content-Encoding".parse().unwrap(),
"Accept-Encoding".parse().unwrap(),
"DPoP".parse().unwrap(),
"atproto-proxy".parse().unwrap(),
"atproto-accept-labelers".parse().unwrap(),
"x-bsky-topics".parse().unwrap(),
http::header::AUTHORIZATION,
http::header::CONTENT_TYPE,
http::header::CONTENT_ENCODING,
http::header::ACCEPT_ENCODING,
util::HEADER_DPOP,
util::HEADER_ATPROTO_PROXY,
util::HEADER_ATPROTO_ACCEPT_LABELERS,
util::HEADER_X_BSKY_TOPICS,
])
.expose_headers([
"WWW-Authenticate".parse().unwrap(),
"DPoP-Nonce".parse().unwrap(),
"atproto-repo-rev".parse().unwrap(),
"atproto-content-labelers".parse().unwrap(),
http::header::WWW_AUTHENTICATE,
util::HEADER_DPOP_NONCE,
util::HEADER_ATPROTO_REPO_REV,
util::HEADER_ATPROTO_CONTENT_LABELERS,
]),
)
.with_state(state)
}
async fn rewrite_422_to_400(response: axum::response::Response) -> axum::response::Response {
if response.status() != StatusCode::UNPROCESSABLE_ENTITY {
return response;
}
let (mut parts, body) = response.into_parts();
let bytes = match axum::body::to_bytes(body, 64 * 1024).await {
Ok(b) => b,
Err(_) => {
parts.status = StatusCode::BAD_REQUEST;
parts.headers.remove(http::header::CONTENT_LENGTH);
let fallback = json!({"error": "InvalidRequest", "message": "Invalid request body"});
return axum::response::Response::from_parts(
parts,
axum::body::Body::from(serde_json::to_vec(&fallback).unwrap_or_default()),
);
}
};
let raw = serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
.unwrap_or_else(|| {
String::from_utf8(bytes.to_vec()).unwrap_or_else(|_| "Invalid request body".into())
});
let message = humanize_json_error(&raw);
parts.status = StatusCode::BAD_REQUEST;
parts.headers.remove(http::header::CONTENT_LENGTH);
let error_name = classify_deserialization_error(&raw);
let new_body = json!({
"error": error_name,
"message": message
});
axum::response::Response::from_parts(
parts,
axum::body::Body::from(serde_json::to_vec(&new_body).unwrap_or_default()),
)
}
fn humanize_json_error(raw: &str) -> String {
if raw.contains("missing field") {
raw.split("missing field `")
.nth(1)
.and_then(|s| s.split('`').next())
.map(|field| format!("Missing required field: {}", field))
.unwrap_or_else(|| raw.to_string())
} else if raw.contains("invalid type") {
format!("Invalid field type: {}", raw)
} else if raw.contains("Invalid JSON") || raw.contains("syntax") {
"Invalid JSON syntax".to_string()
} else if raw.contains("Content-Type") || raw.contains("content type") {
"Content-Type must be application/json".to_string()
} else {
raw.to_string()
}
}
fn classify_deserialization_error(raw: &str) -> &'static str {
match raw {
s if s.contains("invalid handle") => "InvalidHandle",
_ => "InvalidRequest",
}
}
@@ -1,5 +1,5 @@
use crate::auth::{BareLoginIdentifier, NormalizedLoginIdentifier};
use crate::comms::{channel_display_name, comms_repo::enqueue_2fa_code};
use crate::comms::comms_repo::enqueue_2fa_code;
use crate::oauth::{
AuthFlow, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, Prompt, SessionId,
db::should_show_consent, scopes::expand_include_scopes,
@@ -23,7 +23,7 @@ use axum::{
use chrono::Utc;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use tranquil_db_traits::ScopePreference;
use tranquil_db_traits::{ScopePreference, WebauthnChallengeType};
use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, RequestId};
use urlencoding::encode as url_encode;
@@ -85,7 +85,7 @@ fn is_valid_scope(s: &str) -> bool {
|| s.starts_with("include:")
}
fn extract_device_cookie(headers: &HeaderMap) -> Option<String> {
fn extract_device_cookie(headers: &HeaderMap) -> Option<tranquil_types::DeviceId> {
headers
.get("cookie")
.and_then(|v| v.to_str().ok())
@@ -94,6 +94,7 @@ fn extract_device_cookie(headers: &HeaderMap) -> Option<String> {
cookie
.strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME))
.and_then(|value| crate::config::AuthConfig::get().verify_device_cookie(value))
.map(tranquil_types::DeviceId::new)
})
})
}
@@ -105,8 +106,8 @@ fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
.map(|s| s.to_string())
}
fn make_device_cookie(device_id: &str) -> String {
let signed_value = crate::config::AuthConfig::get().sign_device_cookie(device_id);
fn make_device_cookie(device_id: &tranquil_types::DeviceId) -> String {
let signed_value = crate::config::AuthConfig::get().sign_device_cookie(device_id.as_str());
format!(
"{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000",
DEVICE_COOKIE_NAME, signed_value
@@ -313,7 +314,7 @@ pub async fn authorize_get(
&& let Some(device_id) = extract_device_cookie(&headers)
&& let Ok(accounts) = state
.oauth_repo
.get_device_accounts(&DeviceIdType::from(device_id.clone()))
.get_device_accounts(&device_id.clone())
.await
&& !accounts.is_empty()
{
@@ -419,8 +420,7 @@ pub async fn authorize_accounts(
.into_response();
}
};
let device_id_typed = DeviceIdType::from(device_id.clone());
let accounts = match state.oauth_repo.get_device_accounts(&device_id_typed).await {
let accounts = match state.oauth_repo.get_device_accounts(&device_id).await {
Ok(accts) => accts,
Err(_) => {
return Json(AccountsResponse {
@@ -693,7 +693,7 @@ pub async fn authorize_post(
"Failed to enqueue 2FA notification"
);
}
let channel_name = channel_display_name(user.preferred_comms_channel);
let channel_name = user.preferred_comms_channel.display_name();
if json_response {
return Json(serde_json::json!({
"needs_2fa": true,
@@ -712,38 +712,37 @@ pub async fn authorize_post(
}
}
}
let mut device_id: Option<String> = extract_device_cookie(&headers);
let mut device_id: Option<DeviceIdType> = extract_device_cookie(&headers);
let mut new_cookie: Option<String> = None;
if form.remember_device {
let final_device_id = if let Some(existing_id) = &device_id {
existing_id.clone()
} else {
let new_id = DeviceId::generate();
let new_device_id_typed = DeviceIdType::new(new_id.0.clone());
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers, None),
last_seen_at: Utc::now(),
};
let new_device_id_typed = DeviceIdType::from(new_id.0.clone());
if state
.oauth_repo
.create_device(&new_device_id_typed, &device_data)
.await
.is_ok()
{
new_cookie = Some(make_device_cookie(&new_id.0));
device_id = Some(new_id.0.clone());
new_cookie = Some(make_device_cookie(&new_device_id_typed));
device_id = Some(new_device_id_typed.clone());
}
new_id.0
new_device_id_typed
};
let final_device_typed = DeviceIdType::from(final_device_id.clone());
let _ = state
.oauth_repo
.upsert_account_device(&user.did, &final_device_typed)
.upsert_account_device(&user.did, &final_device_id)
.await;
}
let set_auth_device_id = device_id.as_ref().map(|d| DeviceIdType::from(d.clone()));
let set_auth_device_id = device_id.clone();
if state
.oauth_repo
.set_authorization_did(&form_request_id, &user.did, set_auth_device_id.as_ref())
@@ -796,7 +795,7 @@ pub async fn authorize_post(
return redirect_see_other(&consent_url);
}
let code = Code::generate();
let auth_post_device_id = device_id.as_ref().map(|d| DeviceIdType::from(d.clone()));
let auth_post_device_id = device_id.clone();
let auth_post_code = AuthorizationCode::from(code.0.clone());
if state
.oauth_repo
@@ -915,7 +914,7 @@ pub async fn authorize_select(
);
}
};
let verify_device_id = DeviceIdType::from(device_id.clone());
let verify_device_id = device_id.clone();
let account_valid = match state
.oauth_repo
.verify_account_on_device(&verify_device_id, &did)
@@ -963,7 +962,7 @@ pub async fn authorize_select(
);
}
let has_totp = crate::api::server::has_totp_enabled(&state, &did).await;
let select_early_device_typed = DeviceIdType::from(device_id.clone());
let select_early_device_typed = device_id.clone();
if has_totp {
if state
.oauth_repo
@@ -1009,7 +1008,7 @@ pub async fn authorize_select(
"Failed to enqueue 2FA notification"
);
}
let channel_name = channel_display_name(user.preferred_comms_channel);
let channel_name = user.preferred_comms_channel.display_name();
return Json(serde_json::json!({
"needs_2fa": true,
"channel": channel_name
@@ -1025,7 +1024,7 @@ pub async fn authorize_select(
}
}
}
let select_device_typed = DeviceIdType::from(device_id.clone());
let select_device_typed = device_id.clone();
let _ = state
.oauth_repo
.upsert_account_device(&did, &select_device_typed)
@@ -1714,7 +1713,7 @@ pub async fn consent_post(
let consent_post_device_id = request_data
.device_id
.as_ref()
.map(|d| DeviceIdType::from(d.0.clone()));
.map(|d| DeviceIdType::new(d.0.clone()));
let consent_post_code = AuthorizationCode::from(code.0.clone());
if state
.oauth_repo
@@ -1837,7 +1836,7 @@ pub async fn authorize_2fa_post(
let _ = state.oauth_repo.delete_2fa_challenge(challenge.id).await;
let code = Code::generate();
let device_id = extract_device_cookie(&headers);
let twofa_totp_device_id = device_id.as_ref().map(|d| DeviceIdType::from(d.clone()));
let twofa_totp_device_id = device_id.clone();
let twofa_totp_code = AuthorizationCode::from(code.0.clone());
if state
.oauth_repo
@@ -1945,7 +1944,7 @@ pub async fn authorize_2fa_post(
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
let code = Code::generate();
let twofa_final_device_id = device_id.as_ref().map(|d| DeviceIdType::from(d.clone()));
let twofa_final_device_id = device_id.clone();
let twofa_final_code = AuthorizationCode::from(code.0.clone());
if state
.oauth_repo
@@ -2273,7 +2272,11 @@ pub async fn passkey_start(
if let Err(e) = state
.user_repo
.save_webauthn_challenge(&user.did, "authentication", &state_json)
.save_webauthn_challenge(
&user.did,
WebauthnChallengeType::Authentication,
&state_json,
)
.await
{
tracing::error!(error = %e, "Failed to save authentication state");
@@ -2461,7 +2464,7 @@ pub async fn passkey_finish(
let auth_state_json = match state
.user_repo
.load_webauthn_challenge(passkey_owner_did, "authentication")
.load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
.await
{
Ok(Some(s)) => s,
@@ -2540,7 +2543,7 @@ pub async fn passkey_finish(
if let Err(e) = state
.user_repo
.delete_webauthn_challenge(passkey_owner_did, "authentication")
.delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
.await
{
tracing::warn!(error = %e, "Failed to delete authentication state");
@@ -2550,7 +2553,10 @@ pub async fn passkey_finish(
let cred_id_bytes = auth_result.cred_id().as_slice();
match state
.user_repo
.update_passkey_counter(cred_id_bytes, auth_result.counter() as i32)
.update_passkey_counter(
cred_id_bytes,
i32::try_from(auth_result.counter()).unwrap_or(i32::MAX),
)
.await
{
Ok(false) => {
@@ -2608,7 +2614,7 @@ pub async fn passkey_finish(
{
tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification");
}
let channel_name = channel_display_name(user.preferred_comms_channel);
let channel_name = user.preferred_comms_channel.display_name();
return Json(serde_json::json!({
"needs_2fa": true,
"channel": channel_name
@@ -2658,7 +2664,7 @@ pub async fn passkey_finish(
}
let code = Code::generate();
let passkey_final_device_id = device_id.as_ref().map(|d| DeviceIdType::from(d.clone()));
let passkey_final_device_id = device_id.clone();
let passkey_final_code = AuthorizationCode::from(code.0.clone());
if state
.oauth_repo
@@ -2844,7 +2850,7 @@ pub async fn authorize_passkey_start(
if let Err(e) = state
.user_repo
.save_webauthn_challenge(&did, "authentication", &state_json)
.save_webauthn_challenge(&did, WebauthnChallengeType::Authentication, &state_json)
.await
{
tracing::error!("Failed to save authentication state: {:?}", e);
@@ -2951,7 +2957,7 @@ pub async fn authorize_passkey_finish(
let auth_state_json = match state
.user_repo
.load_webauthn_challenge(&did, "authentication")
.load_webauthn_challenge(&did, WebauthnChallengeType::Authentication)
.await
{
Ok(Some(s)) => s,
@@ -3025,12 +3031,15 @@ pub async fn authorize_passkey_finish(
let _ = state
.user_repo
.delete_webauthn_challenge(&did, "authentication")
.delete_webauthn_challenge(&did, WebauthnChallengeType::Authentication)
.await;
match state
.user_repo
.update_passkey_counter(credential.id.as_ref(), auth_result.counter() as i32)
.update_passkey_counter(
credential.id.as_ref(),
i32::try_from(auth_result.counter()).unwrap_or(i32::MAX),
)
.await
{
Ok(false) => {
@@ -3101,7 +3110,7 @@ pub async fn authorize_passkey_finish(
{
tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification");
}
let channel_name = channel_display_name(user.preferred_comms_channel);
let channel_name = user.preferred_comms_channel.display_name();
let redirect_url = format!(
"/app/oauth/2fa?request_uri={}&channel={}",
url_encode(&form.request_uri),
@@ -3440,22 +3449,18 @@ pub async fn establish_session(
let (device_id, new_cookie) = match existing_device {
Some(id) => {
let device_typed = DeviceIdType::from(id.clone());
let _ = state
.oauth_repo
.upsert_account_device(did, &device_typed)
.await;
let _ = state.oauth_repo.upsert_account_device(did, &id).await;
(id, None)
}
None => {
let new_id = DeviceId::generate();
let device_typed = DeviceIdType::new(new_id.0.clone());
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers, None),
last_seen_at: Utc::now(),
};
let device_typed = DeviceIdType::from(new_id.0.clone());
if let Err(e) = state
.oauth_repo
@@ -3489,7 +3494,8 @@ pub async fn establish_session(
.into_response();
}
(new_id.0.clone(), Some(make_device_cookie(&new_id.0)))
let cookie = make_device_cookie(&device_typed);
(device_typed, Some(cookie))
}
};
@@ -131,7 +131,7 @@ pub async fn pushed_authorization_request(
axum::http::StatusCode::CREATED,
Json(ParResponse {
request_uri: request_id.0,
expires_in: PAR_EXPIRY_SECONDS as u64,
expires_in: u64::try_from(PAR_EXPIRY_SECONDS).unwrap_or(600),
}),
))
}
@@ -1,5 +1,7 @@
use super::helpers::{create_access_token_with_delegation, verify_pkce};
use super::types::{TokenGrant, TokenResponse, ValidatedTokenRequest};
use super::types::{
RequestClientAuth, TokenGrant, TokenResponse, TokenType, ValidatedTokenRequest,
};
use crate::config::AuthConfig;
use crate::delegation::intersect_scopes;
use crate::oauth::{
@@ -12,12 +14,12 @@ use crate::oauth::{
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::Json;
use axum::http::HeaderMap;
use axum::http::{HeaderMap, Method};
use chrono::{Duration, Utc};
use tranquil_db_traits::RefreshTokenLookup;
use tranquil_types::{AuthorizationCode, Did, RefreshToken as RefreshTokenType};
const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 300;
const ACCESS_TOKEN_EXPIRY_SECONDS: u64 = 300;
const REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL: i64 = 60;
const REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC: i64 = 14;
@@ -29,7 +31,7 @@ pub async fn handle_authorization_code_grant(
) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> {
tracing::info!(
has_dpop = dpop_proof.is_some(),
client_id = ?request.client_auth.client_id,
client_id = ?request.client_auth.client_id(),
"Authorization code grant requested"
);
let (code, code_verifier, redirect_uri) = match request.grant {
@@ -59,32 +61,33 @@ pub async fn handle_authorization_code_grant(
.require_authorized()
.map_err(|_| OAuthError::InvalidGrant("Authorization not completed".to_string()))?;
if let Some(request_client_id) = &request.client_auth.client_id
&& request_client_id != &authorized.client_id
if let Some(request_client_id) = request.client_auth.client_id()
&& request_client_id != authorized.client_id
{
return Err(OAuthError::InvalidGrant("client_id mismatch".to_string()));
}
let did = authorized.did.to_string();
let client_metadata_cache = ClientMetadataCache::new(3600);
let client_metadata = client_metadata_cache.get(&authorized.client_id).await?;
let client_auth = if let (Some(assertion), Some(assertion_type)) = (
&request.client_auth.client_assertion,
&request.client_auth.client_assertion_type,
) {
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
return Err(OAuthError::InvalidClient(
"Unsupported client_assertion_type".to_string(),
));
let client_auth = match &request.client_auth {
RequestClientAuth::PrivateKeyJwt {
assertion,
assertion_type,
..
} => {
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
return Err(OAuthError::InvalidClient(
"Unsupported client_assertion_type".to_string(),
));
}
ClientAuth::PrivateKeyJwt {
client_assertion: assertion.clone(),
}
}
ClientAuth::PrivateKeyJwt {
client_assertion: assertion.clone(),
}
} else if let Some(secret) = &request.client_auth.client_secret {
ClientAuth::SecretPost {
client_secret: secret.clone(),
}
} else {
ClientAuth::None
RequestClientAuth::SecretPost { client_secret, .. } => ClientAuth::SecretPost {
client_secret: client_secret.clone(),
},
RequestClientAuth::None { .. } => ClientAuth::None,
};
verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?;
verify_pkce(&authorized.parameters.code_challenge, &code_verifier)?;
@@ -100,7 +103,7 @@ pub async fn handle_authorization_code_grant(
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
let pds_hostname = pds_hostname();
let token_endpoint = format!("https://{}/oauth/token", pds_hostname);
let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?;
let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?;
if !state
.oauth_repo
.check_and_record_dpop_jti(&result.jti)
@@ -220,13 +223,20 @@ pub async fn handle_authorization_code_grant(
let mut response_headers = HeaderMap::new();
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap());
let nonce = verifier.generate_nonce();
let nonce_header = nonce.parse().map_err(|_| {
OAuthError::ServerError("Failed to encode DPoP nonce as header value".to_string())
})?;
response_headers.insert("DPoP-Nonce", nonce_header);
Ok((
response_headers,
Json(TokenResponse {
access_token,
token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(),
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64,
token_type: match dpop_jkt {
Some(_) => TokenType::DPoP,
None => TokenType::Bearer,
},
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS,
refresh_token: Some(refresh_token.0),
scope: final_scope,
sub: Some(did),
@@ -283,13 +293,20 @@ pub async fn handle_refresh_token_grant(
let mut response_headers = HeaderMap::new();
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap());
let nonce = verifier.generate_nonce();
let nonce_header = nonce.parse().map_err(|_| {
OAuthError::ServerError("Failed to encode DPoP nonce as header value".to_string())
})?;
response_headers.insert("DPoP-Nonce", nonce_header);
return Ok((
response_headers,
Json(TokenResponse {
access_token,
token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(),
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64,
token_type: match dpop_jkt {
Some(_) => TokenType::DPoP,
None => TokenType::Bearer,
},
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS,
refresh_token: token_data.current_refresh_token.map(|r| r.0),
scope: token_data.scope,
sub: Some(token_data.did.to_string()),
@@ -333,7 +350,7 @@ pub async fn handle_refresh_token_grant(
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
let pds_hostname = pds_hostname();
let token_endpoint = format!("https://{}/oauth/token", pds_hostname);
let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?;
let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?;
if !state
.oauth_repo
.check_and_record_dpop_jti(&result.jti)
@@ -387,13 +404,20 @@ pub async fn handle_refresh_token_grant(
let mut response_headers = HeaderMap::new();
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap());
let nonce = verifier.generate_nonce();
let nonce_header = nonce.parse().map_err(|_| {
OAuthError::ServerError("Failed to encode DPoP nonce as header value".to_string())
})?;
response_headers.insert("DPoP-Nonce", nonce_header);
Ok((
response_headers,
Json(TokenResponse {
access_token,
token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(),
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64,
token_type: match dpop_jkt {
Some(_) => TokenType::DPoP,
None => TokenType::Bearer,
},
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS,
refresh_token: Some(new_refresh_token.0),
scope: token_data.scope,
sub: Some(token_data.did.to_string()),
@@ -77,8 +77,14 @@ pub fn create_access_token_with_delegation(
"alg": "HS256",
"typ": "at+jwt"
});
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let header_b64 =
URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).map_err(|_| {
OAuthError::ServerError("token header serialization failed".to_string())
})?);
let payload_b64 =
URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).map_err(|_| {
OAuthError::ServerError("token payload serialization failed".to_string())
})?);
let signing_input = format!("{}.{}", header_b64, payload_b64);
let config = AuthConfig::get();
type HmacSha256 = hmac::Hmac<Sha256>;
@@ -15,7 +15,7 @@ pub use introspect::{
IntrospectRequest, IntrospectResponse, RevokeRequest, introspect_token, revoke_token,
};
pub use types::{
ClientAuthParams, GrantType, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest,
GrantType, RequestClientAuth, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest,
};
pub async fn token_endpoint(
@@ -41,7 +41,7 @@ pub async fn token_endpoint(
));
};
let dpop_proof = headers
.get("DPoP")
.get(crate::util::HEADER_DPOP)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let validated = request.validate()?;
@@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize};
pub enum GrantType {
AuthorizationCode,
RefreshToken,
Unsupported(String),
}
impl GrantType {
@@ -13,45 +12,36 @@ impl GrantType {
match self {
Self::AuthorizationCode => "authorization_code",
Self::RefreshToken => "refresh_token",
Self::Unsupported(s) => s,
}
}
}
#[derive(Debug, Clone)]
pub struct UnsupportedGrantType(pub String);
impl std::fmt::Display for UnsupportedGrantType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "unsupported grant type: {}", self.0)
}
}
impl std::error::Error for UnsupportedGrantType {}
impl std::str::FromStr for GrantType {
type Err = std::convert::Infallible;
type Err = UnsupportedGrantType;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"authorization_code" => Self::AuthorizationCode,
"refresh_token" => Self::RefreshToken,
other => Self::Unsupported(other.to_string()),
})
}
}
impl<'de> Deserialize<'de> for GrantType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(s.parse().unwrap())
}
}
impl Serialize for GrantType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
match s {
"authorization_code" => Ok(Self::AuthorizationCode),
"refresh_token" => Ok(Self::RefreshToken),
other => Err(UnsupportedGrantType(other.to_string())),
}
}
}
#[derive(Debug, Deserialize)]
pub struct TokenRequest {
pub grant_type: GrantType,
pub grant_type: String,
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
@@ -82,23 +72,45 @@ pub enum TokenGrant {
},
}
#[derive(Debug, Clone, Default)]
pub struct ClientAuthParams {
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub client_assertion: Option<String>,
pub client_assertion_type: Option<String>,
#[derive(Debug, Clone)]
pub enum RequestClientAuth {
None {
client_id: Option<String>,
},
SecretPost {
client_id: Option<String>,
client_secret: String,
},
PrivateKeyJwt {
client_id: Option<String>,
assertion: String,
assertion_type: String,
},
}
impl RequestClientAuth {
pub fn client_id(&self) -> Option<&str> {
match self {
Self::None { client_id }
| Self::SecretPost { client_id, .. }
| Self::PrivateKeyJwt { client_id, .. } => client_id.as_deref(),
}
}
}
#[derive(Debug, Clone)]
pub struct ValidatedTokenRequest {
pub grant: TokenGrant,
pub client_auth: ClientAuthParams,
pub client_auth: RequestClientAuth,
}
impl TokenRequest {
pub fn validate(self) -> Result<ValidatedTokenRequest, OAuthError> {
let grant = match self.grant_type {
let grant_type: GrantType = self
.grant_type
.parse()
.map_err(|e: UnsupportedGrantType| OAuthError::UnsupportedGrantType(e.0))?;
let grant = match grant_type {
GrantType::AuthorizationCode => {
let code = self.code.ok_or_else(|| {
OAuthError::InvalidRequest(
@@ -124,26 +136,41 @@ impl TokenRequest {
})?;
TokenGrant::RefreshToken { refresh_token }
}
GrantType::Unsupported(grant_type) => {
return Err(OAuthError::UnsupportedGrantType(grant_type));
}
};
let client_auth = ClientAuthParams {
client_id: self.client_id,
client_secret: self.client_secret,
client_assertion: self.client_assertion,
client_assertion_type: self.client_assertion_type,
let client_auth = match (self.client_assertion, self.client_assertion_type) {
(Some(assertion), Some(assertion_type)) => RequestClientAuth::PrivateKeyJwt {
client_id: self.client_id,
assertion,
assertion_type,
},
_ => match self.client_secret {
Some(secret) => RequestClientAuth::SecretPost {
client_id: self.client_id,
client_secret: secret,
},
None => RequestClientAuth::None {
client_id: self.client_id,
},
},
};
Ok(ValidatedTokenRequest { grant, client_auth })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "PascalCase")]
pub enum TokenType {
Bearer,
#[serde(rename = "DPoP")]
DPoP,
}
#[derive(Debug, Serialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub token_type: TokenType,
pub expires_in: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
+22 -10
View File
@@ -12,6 +12,7 @@ use subtle::ConstantTimeEq;
use tranquil_db_traits::{OAuthRepository, UserRepository};
use tranquil_types::{ClientId, TokenId};
use crate::auth::AuthSource;
use crate::types::Did;
use super::scopes::ScopePermissions;
@@ -217,7 +218,7 @@ pub struct OAuthUser {
pub did: Did,
pub client_id: Option<ClientId>,
pub scope: Option<String>,
pub is_oauth: bool,
pub auth_source: AuthSource,
pub permissions: ScopePermissions,
}
@@ -240,14 +241,22 @@ impl IntoResponse for OAuthAuthError {
)
.into_response();
if let Some(nonce) = self.dpop_nonce {
response
.headers_mut()
.insert("DPoP-Nonce", nonce.parse().unwrap());
match nonce.parse() {
Ok(val) => {
response.headers_mut().insert("DPoP-Nonce", val);
}
Err(e) => tracing::warn!(error = %e, "DPoP-Nonce header value failed to encode"),
}
}
if let Some(www_auth) = self.www_authenticate {
response
.headers_mut()
.insert("WWW-Authenticate", www_auth.parse().unwrap());
match www_auth.parse() {
Ok(val) => {
response.headers_mut().insert("WWW-Authenticate", val);
}
Err(e) => {
tracing::warn!(error = %e, "WWW-Authenticate header value failed to encode")
}
}
}
response
}
@@ -289,13 +298,16 @@ impl FromRequestParts<AppState> for OAuthUser {
www_authenticate: None,
});
};
let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok());
let dpop_proof = parts
.headers
.get(crate::util::HEADER_DPOP)
.and_then(|v| v.to_str().ok());
if let Ok(result) = try_legacy_auth(state.user_repo.as_ref(), token).await {
return Ok(OAuthUser {
did: result.did,
client_id: None,
scope: None,
is_oauth: false,
auth_source: AuthSource::Session,
permissions: ScopePermissions::default(),
});
}
@@ -316,7 +328,7 @@ impl FromRequestParts<AppState> for OAuthUser {
did: result.did,
client_id: Some(result.client_id),
scope: result.scope,
is_oauth: true,
auth_source: AuthSource::OAuth,
permissions,
})
}
+71 -49
View File
@@ -31,10 +31,41 @@ pub enum PlcError {
CircuitBreakerOpen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlcOpType {
#[serde(rename = "plc_operation")]
Operation,
#[serde(rename = "plc_tombstone")]
Tombstone,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ServiceType {
#[serde(rename = "AtprotoPersonalDataServer")]
Pds,
#[serde(rename = "AtprotoAppView")]
AppView,
}
impl ServiceType {
pub fn as_str(self) -> &'static str {
match self {
Self::Pds => "AtprotoPersonalDataServer",
Self::AppView => "AtprotoAppView",
}
}
pub fn is_pds(self) -> bool {
matches!(self, Self::Pds)
}
}
pub const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlcOperation {
#[serde(rename = "type")]
pub op_type: String,
pub op_type: PlcOpType,
#[serde(rename = "rotationKeys")]
pub rotation_keys: Vec<String>,
#[serde(rename = "verificationMethods")]
@@ -50,14 +81,14 @@ pub struct PlcOperation {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlcService {
#[serde(rename = "type")]
pub service_type: String,
pub service_type: ServiceType,
pub endpoint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlcTombstone {
#[serde(rename = "type")]
pub op_type: String,
pub op_type: PlcOpType,
pub prev: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub sig: Option<String>,
@@ -74,7 +105,7 @@ impl PlcOpOrTombstone {
pub fn is_tombstone(&self) -> bool {
match self {
PlcOpOrTombstone::Tombstone(_) => true,
PlcOpOrTombstone::Operation(op) => op.op_type == "plc_tombstone",
PlcOpOrTombstone::Operation(op) => op.op_type == PlcOpType::Tombstone,
}
}
}
@@ -124,7 +155,7 @@ impl PlcClient {
}
pub async fn get_document(&self, did: &str) -> Result<Value, PlcError> {
let cache_key = format!("plc:doc:{}", did);
let cache_key = crate::cache_keys::plc_doc_key(did);
if let Some(ref cache) = self.cache
&& let Some(cached) = cache.get(&cache_key).await
&& let Ok(value) = serde_json::from_str(&cached)
@@ -163,7 +194,7 @@ impl PlcClient {
}
pub async fn get_document_data(&self, did: &str) -> Result<Value, PlcError> {
let cache_key = format!("plc:data:{}", did);
let cache_key = crate::cache_keys::plc_data_key(did);
if let Some(ref cache) = self.cache
&& let Some(cached) = cache.get(&cache_key).await
&& let Ok(value) = serde_json::from_str(&cached)
@@ -313,7 +344,7 @@ pub fn create_update_op(
}
};
let new_op = PlcOperation {
op_type: "plc_operation".to_string(),
op_type: PlcOpType::Operation,
rotation_keys: rotation_keys.unwrap_or(base_rotation_keys),
verification_methods: verification_methods.unwrap_or(base_verification_methods),
also_known_as: also_known_as.unwrap_or(base_also_known_as),
@@ -328,7 +359,7 @@ pub fn signing_key_to_did_key(signing_key: &SigningKey) -> String {
let verifying_key = signing_key.verifying_key();
let point = verifying_key.to_encoded_point(true);
let compressed_bytes = point.as_bytes();
let mut prefixed = vec![0xe7, 0x01];
let mut prefixed = Vec::from(SECP256K1_MULTICODEC_PREFIX);
prefixed.extend_from_slice(compressed_bytes);
let encoded = multibase::encode(multibase::Base::Base58Btc, &prefixed);
format!("did:key:{}", encoded)
@@ -352,12 +383,12 @@ pub fn create_genesis_operation(
services.insert(
"atproto_pds".to_string(),
PlcService {
service_type: "AtprotoPersonalDataServer".to_string(),
service_type: ServiceType::Pds,
endpoint: pds_endpoint.to_string(),
},
);
let genesis_op = PlcOperation {
op_type: "plc_operation".to_string(),
op_type: PlcOpType::Operation,
rotation_keys: vec![rotation_key.to_string()],
verification_methods,
also_known_as: vec![format!("at://{}", handle)],
@@ -386,42 +417,39 @@ pub fn did_for_genesis_op(signed_op: &Value) -> Result<String, PlcError> {
Ok(format!("did:plc:{}", truncated))
}
pub fn validate_plc_operation(op: &Value) -> Result<(), PlcError> {
pub fn validate_plc_operation(op: &Value) -> Result<PlcOpType, PlcError> {
let obj = op
.as_object()
.ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?;
let op_type = obj
let op_type_str = obj
.get("type")
.and_then(|v| v.as_str())
.ok_or_else(|| PlcError::InvalidResponse("Missing type field".to_string()))?;
if op_type != "plc_operation" && op_type != "plc_tombstone" {
return Err(PlcError::InvalidResponse(format!(
let op_type: PlcOpType = serde_json::from_value(op_type_str.clone()).map_err(|_| {
PlcError::InvalidResponse(format!(
"Invalid type: {}",
op_type
)));
}
if op_type == "plc_operation" {
if obj.get("rotationKeys").is_none() {
return Err(PlcError::InvalidResponse(
"Missing rotationKeys".to_string(),
));
}
if obj.get("verificationMethods").is_none() {
return Err(PlcError::InvalidResponse(
"Missing verificationMethods".to_string(),
));
}
if obj.get("alsoKnownAs").is_none() {
return Err(PlcError::InvalidResponse("Missing alsoKnownAs".to_string()));
}
if obj.get("services").is_none() {
return Err(PlcError::InvalidResponse("Missing services".to_string()));
op_type_str.as_str().unwrap_or("<non-string>")
))
})?;
match op_type {
PlcOpType::Operation => {
let required_fields = [
"rotationKeys",
"verificationMethods",
"alsoKnownAs",
"services",
];
required_fields.iter().try_for_each(|field| {
obj.get(*field)
.map(|_| ())
.ok_or_else(|| PlcError::InvalidResponse(format!("Missing {}", field)))
})?;
}
PlcOpType::Tombstone => {}
}
if obj.get("sig").is_none() {
return Err(PlcError::InvalidResponse("Missing sig".to_string()));
}
Ok(())
Ok(op_type)
}
pub struct PlcValidationContext {
@@ -435,14 +463,13 @@ pub fn validate_plc_operation_for_submission(
op: &Value,
ctx: &PlcValidationContext,
) -> Result<(), PlcError> {
validate_plc_operation(op)?;
let op_type = validate_plc_operation(op)?;
if op_type != PlcOpType::Operation {
return Ok(());
}
let obj = op
.as_object()
.ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?;
let op_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
if op_type != "plc_operation" {
return Ok(());
}
let rotation_keys = obj
.get("rotationKeys")
.and_then(|v| v.as_array())
@@ -489,7 +516,7 @@ pub fn validate_plc_operation_for_submission(
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
if service_type != "AtprotoPersonalDataServer" {
if service_type != ServiceType::Pds.as_str() {
return Err(PlcError::InvalidResponse(
"Incorrect type on atproto_pds service".to_string(),
));
@@ -551,18 +578,13 @@ fn verify_signature_with_did_key(
"Invalid did:key data".to_string(),
));
}
let (codec, key_bytes) = if decoded[0] == 0xe7 && decoded[1] == 0x01 {
(0xe701u16, &decoded[2..])
let key_bytes = if decoded.starts_with(&SECP256K1_MULTICODEC_PREFIX) {
&decoded[SECP256K1_MULTICODEC_PREFIX.len()..]
} else {
return Err(PlcError::InvalidResponse(
"Unsupported key type in did:key".to_string(),
"Unsupported key type in did:key (expected secp256k1)".to_string(),
));
};
if codec != 0xe701 {
return Err(PlcError::InvalidResponse(
"Only secp256k1 keys are supported".to_string(),
));
}
let verifying_key = VerifyingKey::from_sec1_bytes(key_bytes)
.map_err(|e| PlcError::InvalidResponse(format!("Invalid public key: {}", e)))?;
Ok(verifying_key.verify(message, signature).is_ok())
+29 -29
View File
@@ -46,121 +46,121 @@ impl RateLimiters {
pub fn new() -> Self {
Self {
login: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
const { NonZeroU32::new(10).unwrap() },
))),
oauth_token: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(300).unwrap(),
const { NonZeroU32::new(300).unwrap() },
))),
oauth_authorize: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
const { NonZeroU32::new(10).unwrap() },
))),
password_reset: Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(5).unwrap(),
const { NonZeroU32::new(5).unwrap() },
))),
account_creation: Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(10).unwrap(),
const { NonZeroU32::new(10).unwrap() },
))),
refresh_session: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(60).unwrap(),
const { NonZeroU32::new(60).unwrap() },
))),
reset_password: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
const { NonZeroU32::new(10).unwrap() },
))),
oauth_par: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(30).unwrap(),
const { NonZeroU32::new(30).unwrap() },
))),
oauth_introspect: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(30).unwrap(),
const { NonZeroU32::new(30).unwrap() },
))),
app_password: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
const { NonZeroU32::new(10).unwrap() },
))),
email_update: Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(5).unwrap(),
const { NonZeroU32::new(5).unwrap() },
))),
totp_verify: Arc::new(RateLimiter::keyed(
Quota::with_period(std::time::Duration::from_secs(60))
.unwrap()
.allow_burst(NonZeroU32::new(5).unwrap()),
.allow_burst(const { NonZeroU32::new(5).unwrap() }),
)),
handle_update: Arc::new(RateLimiter::keyed(
Quota::with_period(std::time::Duration::from_secs(30))
.unwrap()
.allow_burst(NonZeroU32::new(10).unwrap()),
.allow_burst(const { NonZeroU32::new(10).unwrap() }),
)),
handle_update_daily: Arc::new(RateLimiter::keyed(
Quota::with_period(std::time::Duration::from_secs(1728))
.unwrap()
.allow_burst(NonZeroU32::new(50).unwrap()),
.allow_burst(const { NonZeroU32::new(50).unwrap() }),
)),
verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(60).unwrap(),
const { NonZeroU32::new(60).unwrap() },
))),
sso_initiate: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
const { NonZeroU32::new(10).unwrap() },
))),
sso_callback: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(30).unwrap(),
const { NonZeroU32::new(30).unwrap() },
))),
sso_unlink: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
const { NonZeroU32::new(10).unwrap() },
))),
oauth_register_complete: Arc::new(RateLimiter::keyed(
Quota::with_period(std::time::Duration::from_secs(60))
.unwrap()
.allow_burst(NonZeroU32::new(5).unwrap()),
.allow_burst(const { NonZeroU32::new(5).unwrap() }),
)),
handle_verification: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
const { NonZeroU32::new(10).unwrap() },
))),
}
}
pub fn with_login_limit(mut self, per_minute: u32) -> Self {
self.login = Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()),
NonZeroU32::new(per_minute).unwrap_or(const { NonZeroU32::new(10).unwrap() }),
)));
self
}
pub fn with_oauth_token_limit(mut self, per_minute: u32) -> Self {
self.oauth_token = Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(30).unwrap()),
NonZeroU32::new(per_minute).unwrap_or(const { NonZeroU32::new(30).unwrap() }),
)));
self
}
pub fn with_oauth_authorize_limit(mut self, per_minute: u32) -> Self {
self.oauth_authorize = Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()),
NonZeroU32::new(per_minute).unwrap_or(const { NonZeroU32::new(10).unwrap() }),
)));
self
}
pub fn with_password_reset_limit(mut self, per_hour: u32) -> Self {
self.password_reset = Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()),
NonZeroU32::new(per_hour).unwrap_or(const { NonZeroU32::new(5).unwrap() }),
)));
self
}
pub fn with_account_creation_limit(mut self, per_hour: u32) -> Self {
self.account_creation = Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(10).unwrap()),
NonZeroU32::new(per_hour).unwrap_or(const { NonZeroU32::new(10).unwrap() }),
)));
self
}
pub fn with_email_update_limit(mut self, per_hour: u32) -> Self {
self.email_update = Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()),
NonZeroU32::new(per_hour).unwrap_or(const { NonZeroU32::new(5).unwrap() }),
)));
self
}
pub fn with_sso_initiate_limit(mut self, per_minute: u32) -> Self {
self.sso_initiate = Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()),
NonZeroU32::new(per_minute).unwrap_or(const { NonZeroU32::new(10).unwrap() }),
)));
self
}
@@ -178,7 +178,7 @@ mod tests {
#[test]
fn test_rate_limiter_exhaustion() {
let limiter = RateLimiter::keyed(Quota::per_minute(NonZeroU32::new(2).unwrap()));
let limiter = RateLimiter::keyed(Quota::per_minute(const { NonZeroU32::new(2).unwrap() }));
let key = "test_ip".to_string();
assert!(limiter.check_key(&key).is_ok());
@@ -188,7 +188,7 @@ mod tests {
#[test]
fn test_different_keys_have_separate_limits() {
let limiter = RateLimiter::keyed(Quota::per_minute(NonZeroU32::new(1).unwrap()));
let limiter = RateLimiter::keyed(Quota::per_minute(const { NonZeroU32::new(1).unwrap() }));
assert!(limiter.check_key(&"ip1".to_string()).is_ok());
assert!(limiter.check_key(&"ip1".to_string()).is_err());

Some files were not shown because too many files have changed in this diff Show More