1use chrono::{Duration, Utc};
8use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
9use secrecy::{ExposeSecret, SecretString};
10use serde::{Serialize, de::DeserializeOwned};
11use uuid::Uuid;
12
13pub const DOWNLOAD_CLAIM_PARAM: &str = "download-claim";
15
16pub const DEVELOPMENT_JWT_PASSWORD: &str =
19 "sMG87WlKnNZoITzvL2+jczriTR7JRsCtGu/bSKaSIvw=asdfjklasd***FSDfsdASDFDS";
20
21#[derive(Clone, Debug)]
22pub struct JwtKey(Vec<u8>);
23
24impl JwtKey {
25 pub fn new(key: &SecretString) -> anyhow::Result<Self> {
30 if key.expose_secret().trim().is_empty() {
31 anyhow::bail!("JWT_PASSWORD cannot be empty");
32 }
33 Ok(Self(key.expose_secret().as_bytes().to_vec()))
34 }
35
36 pub fn test_key() -> Self {
39 Self(DEVELOPMENT_JWT_PASSWORD.as_bytes().to_vec())
40 }
41}
42
43pub fn sign_hs256_claim<T: Serialize>(
44 claim: &T,
45 key: &JwtKey,
46) -> Result<String, jsonwebtoken::errors::Error> {
47 encode(
48 &Header::new(Algorithm::HS256),
49 claim,
50 &EncodingKey::from_secret(&key.0),
51 )
52}
53
54pub fn validate_hs256_claim<T: DeserializeOwned>(
56 token: &str,
57 key: &JwtKey,
58) -> Result<T, jsonwebtoken::errors::Error> {
59 let validation = Validation::new(Algorithm::HS256);
60 decode::<T>(token, &DecodingKey::from_secret(&key.0), &validation)
61 .map(|token_data| token_data.claims)
62}
63
64#[derive(Debug, Serialize, serde::Deserialize)]
71pub struct DownloadClaim {
72 file_upload_id: Uuid,
73 exp: usize,
74 iat: usize,
75}
76
77impl DownloadClaim {
78 pub fn file_upload_id(&self) -> Uuid {
79 self.file_upload_id
80 }
81
82 pub fn expiring_in_1_day(file_upload_id: Uuid) -> Self {
85 Self::expiring_in(file_upload_id, Duration::days(1))
86 }
87
88 pub fn expiring_in_1_hour(file_upload_id: Uuid) -> Self {
91 Self::expiring_in(file_upload_id, Duration::hours(1))
92 }
93
94 fn expiring_in(file_upload_id: Uuid, lifetime: Duration) -> Self {
95 let now = Utc::now().timestamp().max(0) as usize;
96 let exp = (Utc::now().timestamp() + lifetime.num_seconds()).max(0) as usize;
97 Self {
98 file_upload_id,
99 exp,
100 iat: now,
101 }
102 }
103
104 pub fn sign(self, key: &JwtKey) -> Result<String, jsonwebtoken::errors::Error> {
105 sign_hs256_claim(&self, key)
106 }
107
108 pub fn validate(token: &str, key: &JwtKey) -> Result<Self, jsonwebtoken::errors::Error> {
109 validate_hs256_claim(token, key)
110 }
111}
112
113pub fn claimed_file_url(
118 base_url: &str,
119 key: &JwtKey,
120 claim: DownloadClaim,
121) -> Result<String, jsonwebtoken::errors::Error> {
122 let file_upload_id = claim.file_upload_id();
123 let token = claim.sign(key)?;
124 Ok(format!(
125 "{base_url}/api/v0/files/claimed/{file_upload_id}?{DOWNLOAD_CLAIM_PARAM}={token}"
126 ))
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use chrono::Duration;
133
134 fn other_key() -> JwtKey {
135 JwtKey::new(&SecretString::new(
136 "a-completely-different-jwt-secret-0123456789"
137 .to_string()
138 .into(),
139 ))
140 .expect("test key")
141 }
142
143 fn past_timestamp(seconds_ago: i64) -> i64 {
144 (Utc::now() - Duration::seconds(seconds_ago)).timestamp()
145 }
146
147 #[test]
148 fn an_empty_jwt_password_is_rejected() {
149 for secret in ["", " ", "\t\n"] {
150 JwtKey::new(&SecretString::new(secret.to_string().into()))
151 .expect_err("a blank secret must be rejected");
152 }
153 }
154
155 #[test]
156 fn download_claim_round_trips() {
157 let key = JwtKey::test_key();
158 let file_upload_id = Uuid::new_v4();
159 let token = DownloadClaim::expiring_in_1_day(file_upload_id)
160 .sign(&key)
161 .expect("signing should succeed");
162 let claim = DownloadClaim::validate(&token, &key).expect("the claim should validate");
163 assert_eq!(claim.file_upload_id(), file_upload_id);
164 }
165
166 #[test]
168 fn download_claim_expires_in_a_day() {
169 let claim = DownloadClaim::expiring_in_1_day(Uuid::new_v4());
170 let lifetime = claim.exp as i64 - claim.iat as i64;
171 assert_eq!(lifetime, Duration::days(1).num_seconds());
172 }
173
174 #[test]
176 fn download_claim_expires_in_an_hour() {
177 let claim = DownloadClaim::expiring_in_1_hour(Uuid::new_v4());
178 let lifetime = claim.exp as i64 - claim.iat as i64;
179 assert_eq!(lifetime, Duration::hours(1).num_seconds());
180 }
181
182 #[test]
183 fn expired_download_claim_is_rejected() {
184 let key = JwtKey::test_key();
185 let token = sign_hs256_claim(
186 &serde_json::json!({
187 "file_upload_id": Uuid::new_v4(),
188 "exp": past_timestamp(3600),
189 "iat": past_timestamp(7200),
190 }),
191 &key,
192 )
193 .expect("signing should succeed");
194 DownloadClaim::validate(&token, &key).expect_err("an expired claim must be rejected");
195 }
196
197 #[test]
198 fn download_claim_signed_with_another_key_is_rejected() {
199 let token = DownloadClaim::expiring_in_1_day(Uuid::new_v4())
200 .sign(&other_key())
201 .expect("signing should succeed");
202 DownloadClaim::validate(&token, &JwtKey::test_key())
203 .expect_err("a claim signed with another key must be rejected");
204 }
205
206 #[test]
207 fn a_claimed_file_url_names_the_file_it_authorizes() {
208 let key = JwtKey::test_key();
209 let file_upload_id = Uuid::new_v4();
210 let url = claimed_file_url(
211 "http://project-331.local",
212 &key,
213 DownloadClaim::expiring_in_1_hour(file_upload_id),
214 )
215 .expect("the url should be built");
216
217 let (path, query) = url
218 .strip_prefix("http://project-331.local/api/v0/files/claimed/")
219 .expect("a claimed-file url")
220 .split_once('?')
221 .expect("a claim in the query string");
222 assert_eq!(path, file_upload_id.to_string());
223 let token = query
224 .strip_prefix(&format!("{DOWNLOAD_CLAIM_PARAM}="))
225 .expect("the claim parameter");
226 let claim = DownloadClaim::validate(token, &key).expect("the claim should validate");
227 assert_eq!(claim.file_upload_id(), file_upload_id);
228 }
229}