mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-07-24 13:42:36 +00:00
fix: bulk type safety improvements, added a couple of tests
This commit is contained in:
@@ -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
@@ -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
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(¤t_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(),
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(¤t_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()
|
||||
|
||||
@@ -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(¤t_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;
|
||||
|
||||
@@ -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,
|
||||
¤t_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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
¶ms.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,
|
||||
¶ms.aud,
|
||||
params.aud.as_str(),
|
||||
lxm_for_token,
|
||||
&key_bytes,
|
||||
) {
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user