Skip to main content

headless_lms_base/
jwt.rs

1//! The HS256 signing key and the claims that are minted below the server crate.
2//!
3//! Lives here rather than beside the rest of the claim machinery in
4//! `headless_lms_server::domain::models_requests` because the answer readers that mint download
5//! URLs sit in `headless-lms-models`, under the server crate.
6
7use 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
13/// Query parameter carrying a [`DownloadClaim`]; the claimed-file route repeats it in a rename.
14pub const DOWNLOAD_CLAIM_PARAM: &str = "download-claim";
15
16/// The fixed password development and test builds sign with. Production reads `JWT_PASSWORD`, so
17/// nothing signed with this is accepted there.
18pub 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    /// Builds the HS256 signing key from the configured secret.
26    ///
27    /// Errors on an empty or whitespace-only secret, which would leave every claim the host mints
28    /// forgeable.
29    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    /// The key [`crate::config::ApplicationConfiguration::mock_conf`] installs, so a test that signs
37    /// a claim by hand matches a server built from that configuration.
38    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
54/// Decodes and verifies an HS256 token into the requested claim type.
55pub 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/// Authorizes the bearer to read one specific host-stored file for a while.
65///
66/// Minted by the host wherever it hands out a URL for a file-typed answer: to an exercise service's
67/// grade endpoint, and to the views that render a submission. The read-side mirror of
68/// `UploadClaim`; unlike it, this claim names a single file rather than a namespace, so a holder
69/// cannot reach any other file.
70#[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    /// A day, not the grading request's 120 s: a service may finish asynchronously through
83    /// `grading_update_url` long after the request returns.
84    pub fn expiring_in_1_day(file_upload_id: Uuid) -> Self {
85        Self::expiring_in(file_upload_id, Duration::days(1))
86    }
87
88    /// For a URL a person is about to click: long enough to leave the page open for a while, short
89    /// enough that a copied link is not a lasting handle on someone else's answer.
90    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
113/// The URL one host-stored file is read through, carrying a claim minted for it here and now.
114///
115/// The claim expires, so the result is good for one reader for one sitting: it must not be
116/// persisted, and a response carrying it cannot be cached for anyone else.
117pub 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    /// A grading request's claims outlive the request itself, but not by more than a day.
167    #[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    /// A URL handed to a person outlives the page load, but not the sitting.
175    #[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}