9754aecfa9
Benchmark-gated (examples/bench_round19_micro.rs, benches/ROUND19.md): every
section ships a BEFORE/AFTER counting-allocator arm with a byte/-value
equivalence gate and a GATE-FAIL-rollback exit. All eight pass. No Postgres.
- M1 verify_basic_auth cache key: blake3::hash(format!("{u}:{p}")) → incremental
Hasher (byte-identical key, 2→0 allocs on every Basic-auth DAV request)
- M2 WopiTokenService: prebuild Validation/DecodingKey/EncodingKey in new()
instead of per-call (mirrors JwtTokenService; 16→12 allocs/validate)
- V1/V2 vCard emit (contact_to_vcard/generate_vcard): FN fallback drops the
throwaway to_string, NOTE skips the escape copy for newline-free notes, REV
uses new common::fmt::compact_ical_utc stack renderer (11.5× vs chrono
strftime, 3→0 allocs); per-contact 9→4 allocs
- M4 trash_service::row_to_item_dto: move name/path/blob_hash out of the owned
row instead of cloning (3 clones/file row gone)
- M5 search cache key: Uuid::hyphenated().encode_lower stack buffer instead of
to_string (identical u64 key, 1→0 allocs/request)
- M6 streaming PROPFIND: reuse one href buffer across the page instead of a
format! per child (native + NC handlers; 192→3 allocs on a 64-child page)
- M7 nextcloud extract_url_user: return Cow instead of forcing into_owned
(zero-alloc on the common ASCII-username path)
common::fmt::compact_ical_utc added with chrono-parity unit tests (CASES +
60-year sweep). cargo fmt + clippy --all-targets clean; 526 lib unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ront9bk7YMoffVQkGG47gh
175 lines
5.8 KiB
Rust
175 lines
5.8 KiB
Rust
//! WOPI access token service.
|
|
//!
|
|
//! Generates and validates WOPI-scoped JWT tokens that are separate from
|
|
//! the regular authentication tokens. Uses the same `jsonwebtoken` crate
|
|
//! but with a distinct `scope: "wopi"` claim to prevent token confusion.
|
|
|
|
use chrono::Utc;
|
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::common::errors::{DomainError, ErrorKind};
|
|
|
|
/// JWT claims for WOPI access tokens.
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct WopiTokenClaims {
|
|
/// User ID
|
|
pub sub: String,
|
|
/// File ID this token grants access to
|
|
pub file_id: String,
|
|
/// Whether the user can write (edit) the file
|
|
pub can_write: bool,
|
|
/// Token scope — always "wopi" to distinguish from auth tokens
|
|
pub scope: String,
|
|
/// Display name for the editor UI
|
|
pub username: String,
|
|
/// Expiration timestamp (seconds since Unix epoch)
|
|
pub exp: i64,
|
|
/// Issued at timestamp
|
|
pub iat: i64,
|
|
}
|
|
|
|
/// Service for generating and validating WOPI access tokens.
|
|
pub struct WopiTokenService {
|
|
/// Pre-built signing key — `EncodingKey::from_secret` copies the secret into
|
|
/// a fresh `Vec` on each call, so build it once (mirrors `JwtTokenService`).
|
|
encoding_key: EncodingKey,
|
|
/// Pre-built verification key — same copy-per-call cost as `encoding_key`.
|
|
decoding_key: DecodingKey,
|
|
/// Pre-built HS256 validation config — `Validation::new` allocates a
|
|
/// `required_spec_claims` HashSet + an `algorithms` Vec; Office/Collabora
|
|
/// hosts poll `validate_token` continuously (benches/ROUND19.md §M2).
|
|
validation: Validation,
|
|
token_ttl_secs: i64,
|
|
}
|
|
|
|
impl WopiTokenService {
|
|
pub fn new(secret: String, token_ttl_secs: i64) -> Self {
|
|
Self {
|
|
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
|
|
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
|
|
validation: Validation::new(Algorithm::HS256),
|
|
token_ttl_secs,
|
|
}
|
|
}
|
|
|
|
/// Generate a WOPI access token for a specific file and user.
|
|
///
|
|
/// Returns `(token_string, expiration_unix_ms)`.
|
|
pub fn generate_token(
|
|
&self,
|
|
file_id: &str,
|
|
user_id: &str,
|
|
username: &str,
|
|
can_write: bool,
|
|
) -> Result<(String, i64), DomainError> {
|
|
let now = Utc::now().timestamp();
|
|
let claims = WopiTokenClaims {
|
|
sub: user_id.to_string(),
|
|
file_id: file_id.to_string(),
|
|
can_write,
|
|
scope: "wopi".to_string(),
|
|
username: username.to_string(),
|
|
exp: now + self.token_ttl_secs,
|
|
iat: now,
|
|
};
|
|
|
|
let token = encode(&Header::default(), &claims, &self.encoding_key).map_err(|e| {
|
|
DomainError::new(
|
|
ErrorKind::InternalError,
|
|
"WopiTokenService",
|
|
format!("Failed to generate WOPI token: {}", e),
|
|
)
|
|
})?;
|
|
|
|
let expires_at_unix_ms = claims.exp * 1000;
|
|
Ok((token, expires_at_unix_ms))
|
|
}
|
|
|
|
/// Validate a WOPI access token and extract its claims.
|
|
pub fn validate_token(&self, token: &str) -> Result<WopiTokenClaims, DomainError> {
|
|
let token_data = decode::<WopiTokenClaims>(token, &self.decoding_key, &self.validation)
|
|
.map_err(|e| match e.kind() {
|
|
jsonwebtoken::errors::ErrorKind::ExpiredSignature => DomainError::new(
|
|
ErrorKind::AccessDenied,
|
|
"WopiTokenService",
|
|
"WOPI token expired",
|
|
),
|
|
_ => DomainError::new(
|
|
ErrorKind::AccessDenied,
|
|
"WopiTokenService",
|
|
format!("Invalid WOPI token: {}", e),
|
|
),
|
|
})?;
|
|
|
|
let claims = token_data.claims;
|
|
|
|
if claims.scope != "wopi" {
|
|
return Err(DomainError::new(
|
|
ErrorKind::AccessDenied,
|
|
"WopiTokenService",
|
|
"Token is not a WOPI token",
|
|
));
|
|
}
|
|
|
|
Ok(claims)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn service() -> WopiTokenService {
|
|
WopiTokenService::new("test_secret_at_least_32_bytes_long!!".to_string(), 3600)
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_and_validate() {
|
|
let svc = service();
|
|
let (token, ttl_ms) = svc
|
|
.generate_token("file-123", "user-456", "test_user", true)
|
|
.expect("Should generate token");
|
|
|
|
let claims = svc.validate_token(&token).expect("Should validate");
|
|
assert_eq!(claims.file_id, "file-123");
|
|
assert_eq!(claims.sub, "user-456");
|
|
assert!(claims.can_write);
|
|
assert_eq!(claims.scope, "wopi");
|
|
assert_eq!(claims.username, "test_user");
|
|
|
|
// access_token_ttl must be absolute UNIX time in milliseconds.
|
|
assert_eq!(ttl_ms, claims.exp * 1000);
|
|
assert!(ttl_ms > claims.iat * 1000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_reject_invalid_token() {
|
|
let svc = service();
|
|
let result = svc.validate_token("garbage");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_reject_wrong_secret() {
|
|
let svc1 = service();
|
|
let svc2 = WopiTokenService::new("different_secret_also_32_bytes!!".to_string(), 3600);
|
|
|
|
let (token, _) = svc1
|
|
.generate_token("file-1", "user-1", "test_user", false)
|
|
.expect("Should generate");
|
|
let result = svc2.validate_token(&token);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_read_only_token() {
|
|
let svc = service();
|
|
let (token, _) = svc
|
|
.generate_token("file-1", "user-1", "test_user", false)
|
|
.expect("Should generate");
|
|
let claims = svc.validate_token(&token).expect("Should validate");
|
|
assert!(!claims.can_write);
|
|
}
|
|
}
|