Skip to main content

soma_auth/
jwt.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use base64::Engine;
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use ed25519_dalek::SigningKey;
7use ed25519_dalek::VerifyingKey;
8use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey};
9use jsonwebtoken::{
10    Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, decode_header, encode,
11};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15use crate::error::AuthError;
16use crate::util::{ensure_restrictive_permissions, now_unix, set_restrictive_permissions};
17
18#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
19pub struct AccessClaims {
20    pub iss: String,
21    pub sub: String,
22    pub aud: String,
23    pub exp: usize,
24    pub iat: usize,
25    pub jti: String,
26    pub scope: String,
27    pub azp: String,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct JwksDocument {
32    pub keys: Vec<JwkKey>,
33}
34
35/// A single OKP/Ed25519 verification key advertised at `/jwks`.
36///
37/// Note the field shape is Ed25519's (`kty=OKP`, `crv=Ed25519`, raw public
38/// point in `x`), not RSA's `n`/`e` — the local IdP migrated from RS256 to
39/// EdDSA. Upstream provider JWKS (Google/Authelia) still use the separate
40/// RSA-shaped `Jwk` type in the crate-internal `oidc` module.
41#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
42pub struct JwkKey {
43    pub kty: String,
44    #[serde(rename = "use")]
45    pub use_: String,
46    pub alg: String,
47    pub kid: String,
48    pub crv: String,
49    pub x: String,
50}
51
52/// One active verification key (kid + decoder), plus the JWK it publishes.
53#[derive(Clone)]
54struct Verifier {
55    kid: String,
56    decoding_key: DecodingKey,
57    jwk: JwkKey,
58}
59
60/// Local access-token signing keys.
61///
62/// Signs with a single active Ed25519 key (`key_id` / `encoding_key`) but
63/// verifies against every key in `verifiers` — the active key plus, after a
64/// [`Self::rotate`], the immediately previous key. Publishing both in the
65/// JWKS and accepting tokens signed by either gives an overlap window so a
66/// key rotation does not instantly invalidate outstanding access tokens;
67/// the previous key ages out on the next rotation (its still-valid tokens
68/// bounded by the access-token TTL).
69#[derive(Clone)]
70pub struct SigningKeys {
71    pub key_id: String,
72    encoding_key: EncodingKey,
73    verifiers: Vec<Verifier>,
74    jwks: JwksDocument,
75}
76
77impl fmt::Debug for SigningKeys {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.debug_struct("SigningKeys")
80            .field("key_id", &self.key_id)
81            .field("verifier_count", &self.verifiers.len())
82            .finish_non_exhaustive()
83    }
84}
85
86impl SigningKeys {
87    /// Load the active signing key from `path` (generating one on first
88    /// run), plus the previous key from the sibling `<path>.prev` slot if a
89    /// prior [`Self::rotate`] left one there.
90    ///
91    /// Any key material that does not parse as an Ed25519 PKCS#8 DER key —
92    /// notably a pre-migration RSA PEM key written by an older release — is
93    /// quarantined to `<path>.retired-<unix-ts>` rather than reused, and a
94    /// fresh Ed25519 key is generated in its place. The retired key is never
95    /// promoted to a verifier: it is being retired precisely because its
96    /// signing path is not the one we trust.
97    pub fn load_or_create(path: &Path) -> Result<Self, AuthError> {
98        if let Some(parent) = path.parent() {
99            std::fs::create_dir_all(parent).map_err(|error| {
100                AuthError::Storage(format!(
101                    "create signing key directory `{}`: {error}",
102                    parent.display()
103                ))
104            })?;
105        }
106
107        let active = load_or_generate_key(path)?;
108        let previous = load_optional_previous_key(&previous_key_path(path))?;
109        Self::from_keys(&active, previous.as_ref())
110    }
111
112    /// Rotate the signing key while keeping the outgoing key valid for one
113    /// overlap window.
114    ///
115    /// Moves the current active key into the `<path>.prev` slot (replacing
116    /// any older previous key) and generates a fresh active key at `path`.
117    /// Access tokens signed by the now-previous key keep validating until
118    /// their TTL expires, because the returned [`SigningKeys`] still carries
119    /// the previous key as a verifier and advertises it in the JWKS. The
120    /// caller is responsible for swapping the new value in (e.g. replacing
121    /// the `Arc<SigningKeys>` in `AuthState`).
122    pub fn rotate(path: &Path) -> Result<Self, AuthError> {
123        let outgoing = load_or_generate_key(path)?;
124        let prev_path = previous_key_path(path);
125        // Move active -> prev (fs::rename replaces any existing prev
126        // atomically on Unix), then harden the relocated file.
127        std::fs::rename(path, &prev_path).map_err(|error| {
128            AuthError::Storage(format!(
129                "rotate signing key `{}` -> `{}`: {error}",
130                path.display(),
131                prev_path.display()
132            ))
133        })?;
134        set_restrictive_permissions(&prev_path)?;
135        let active = generate_signing_key(path)?;
136        // The just-moved key is the overlap verifier; drop the copy we read
137        // before the move in favor of the on-disk one we now know is at
138        // `prev_path` (identical bytes, but keeps the invariant that every
139        // verifier corresponds to a file).
140        drop(outgoing);
141        let previous = load_optional_previous_key(&prev_path)?;
142        Self::from_keys(&active, previous.as_ref())
143    }
144
145    pub fn issue_access_token(&self, claims: &AccessClaims) -> Result<String, AuthError> {
146        let mut header = Header::new(Algorithm::EdDSA);
147        header.kid = Some(self.key_id.clone());
148        encode(&header, &claims, &self.encoding_key)
149            .map_err(|error| AuthError::Storage(format!("encode access token: {error}")))
150    }
151
152    /// Validate access token signature, algorithm, and audience.
153    ///
154    /// NOTE: this method does NOT enforce the `iss` claim. Callers that
155    /// need RFC 7519 issuer validation MUST use
156    /// [`Self::validate_access_token_with_issuer`] instead. This entry
157    /// point is preserved for the lab consumer, which performs its own
158    /// post-decode `iss` check. New consumers should always use the
159    /// issuer-enforcing variant.
160    #[deprecated(note = "Use `validate_access_token_with_issuer` for RFC 7519 §4.1.1 compliance")]
161    pub fn validate_access_token(
162        &self,
163        token: &str,
164        expected_audience: &str,
165    ) -> Result<AccessClaims, AuthError> {
166        let decoding_key = self.decoding_key_for_token(token)?;
167        let mut validation = Validation::new(Algorithm::EdDSA);
168        validation.set_audience(&[expected_audience]);
169        decode::<AccessClaims>(token, decoding_key, &validation)
170            .map(|data| data.claims)
171            .map_err(|_| AuthError::InvalidAccessToken)
172    }
173
174    /// Validate signature, algorithm, audience, AND issuer in a single
175    /// pass — the issuer is enforced via `Validation::set_issuer` BEFORE
176    /// decode (RFC 7519 §4.1.1 compliant) rather than via a manual
177    /// `claims.iss != expected` check after decode.
178    ///
179    /// The verification key is selected by the token's `kid` header, so a
180    /// token signed by the previous key during a rotation overlap still
181    /// validates.
182    pub fn validate_access_token_with_issuer(
183        &self,
184        token: &str,
185        expected_audience: &str,
186        expected_issuer: &str,
187    ) -> Result<AccessClaims, AuthError> {
188        let decoding_key = self.decoding_key_for_token(token)?;
189        let mut validation = Validation::new(Algorithm::EdDSA);
190        validation.set_audience(&[expected_audience]);
191        validation.set_issuer(&[expected_issuer]);
192        decode::<AccessClaims>(token, decoding_key, &validation)
193            .map(|data| data.claims)
194            .map_err(|_| AuthError::InvalidAccessToken)
195    }
196
197    pub const fn jwks(&self) -> &JwksDocument {
198        &self.jwks
199    }
200
201    /// Select the verification key matching the token's `kid` header. A
202    /// token whose `kid` names no known key is rejected up front (rather
203    /// than trial-decoding against every key); a token with no `kid` falls
204    /// back to the active key.
205    fn decoding_key_for_token(&self, token: &str) -> Result<&DecodingKey, AuthError> {
206        let header = decode_header(token).map_err(|_| AuthError::InvalidAccessToken)?;
207        match header.kid {
208            Some(kid) => self
209                .verifiers
210                .iter()
211                .find(|verifier| verifier.kid == kid)
212                .map(|verifier| &verifier.decoding_key)
213                .ok_or(AuthError::InvalidAccessToken),
214            None => self
215                .verifiers
216                .first()
217                .map(|verifier| &verifier.decoding_key)
218                .ok_or(AuthError::InvalidAccessToken),
219        }
220    }
221
222    fn from_keys(active: &SigningKey, previous: Option<&SigningKey>) -> Result<Self, AuthError> {
223        let active_der = active
224            .to_pkcs8_der()
225            .map_err(|error| AuthError::Storage(format!("encode signing key DER: {error}")))?;
226        let active_verifier = build_verifier(&active.verifying_key())?;
227        let key_id = active_verifier.kid.clone();
228
229        let mut verifiers = vec![active_verifier];
230        if let Some(previous) = previous {
231            let previous_verifier = build_verifier(&previous.verifying_key())?;
232            // Guard against a degenerate rotation that left an identical key
233            // in the prev slot — publishing the same kid twice is harmless
234            // but pointless.
235            if previous_verifier.kid != key_id {
236                verifiers.push(previous_verifier);
237            }
238        }
239
240        let jwks = JwksDocument {
241            keys: verifiers
242                .iter()
243                .map(|verifier| verifier.jwk.clone())
244                .collect(),
245        };
246
247        Ok(Self {
248            key_id,
249            encoding_key: EncodingKey::from_ed_der(active_der.as_bytes()),
250            verifiers,
251            jwks,
252        })
253    }
254}
255
256/// Build the kid, decoder, and published JWK for one Ed25519 public key.
257fn build_verifier(public_key: &VerifyingKey) -> Result<Verifier, AuthError> {
258    let public_der = public_key
259        .to_public_key_der()
260        .map_err(|error| AuthError::Storage(format!("encode public key DER: {error}")))?;
261    let digest = Sha256::digest(public_der.as_bytes());
262    let kid = URL_SAFE_NO_PAD.encode(&digest[..12]);
263
264    let jwk = JwkKey {
265        kty: "OKP".to_string(),
266        use_: "sig".to_string(),
267        alg: "EdDSA".to_string(),
268        kid: kid.clone(),
269        crv: "Ed25519".to_string(),
270        x: URL_SAFE_NO_PAD.encode(public_key.as_bytes()),
271    };
272
273    Ok(Verifier {
274        kid,
275        // jsonwebtoken's RustCrypto verifier consumes the raw 32-byte
276        // Ed25519 public point here (its `from_ed_der` name is historical).
277        decoding_key: DecodingKey::from_ed_der(public_key.as_bytes()),
278        jwk,
279    })
280}
281
282fn previous_key_path(path: &Path) -> PathBuf {
283    let mut name = path.file_name().unwrap_or_default().to_os_string();
284    name.push(".prev");
285    path.with_file_name(name)
286}
287
288/// Load an Ed25519 key from `path`, generating one if the file is missing
289/// and quarantining any non-Ed25519 material (legacy RSA) before generating
290/// a replacement.
291fn load_or_generate_key(path: &Path) -> Result<SigningKey, AuthError> {
292    if !path.exists() {
293        return generate_signing_key(path);
294    }
295
296    ensure_restrictive_permissions(path)?;
297    let key_bytes = std::fs::read(path).map_err(|error| {
298        AuthError::Storage(format!("read signing key `{}`: {error}", path.display()))
299    })?;
300    match SigningKey::from_pkcs8_der(&key_bytes) {
301        Ok(key) => {
302            ensure_restrictive_permissions(path)?;
303            Ok(key)
304        }
305        Err(_) => {
306            quarantine_key(path)?;
307            generate_signing_key(path)
308        }
309    }
310}
311
312/// Load the optional previous-key overlap slot. A present-but-unparseable
313/// prev file (e.g. leftover RSA) is quarantined and treated as absent rather
314/// than failing the whole load.
315fn load_optional_previous_key(path: &Path) -> Result<Option<SigningKey>, AuthError> {
316    if !path.exists() {
317        return Ok(None);
318    }
319    ensure_restrictive_permissions(path)?;
320    let key_bytes = std::fs::read(path).map_err(|error| {
321        AuthError::Storage(format!(
322            "read previous signing key `{}`: {error}",
323            path.display()
324        ))
325    })?;
326    match SigningKey::from_pkcs8_der(&key_bytes) {
327        Ok(key) => Ok(Some(key)),
328        Err(_) => {
329            quarantine_key(path)?;
330            Ok(None)
331        }
332    }
333}
334
335/// Rename a key file out of the way to `<path>.retired-<unix-ts>`, keeping
336/// restrictive permissions, so it is neither reused nor deleted.
337fn quarantine_key(path: &Path) -> Result<(), AuthError> {
338    let retired = path.with_extension(format!("retired-{}", now_unix()));
339    std::fs::rename(path, &retired).map_err(|error| {
340        AuthError::Storage(format!(
341            "retire legacy signing key `{}`: {error}",
342            path.display()
343        ))
344    })?;
345    set_restrictive_permissions(&retired)?;
346    Ok(())
347}
348
349fn generate_signing_key(path: &Path) -> Result<SigningKey, AuthError> {
350    let mut bytes = [0_u8; 32];
351    getrandom::fill(&mut bytes)
352        .map_err(|error| AuthError::Storage(format!("generate Ed25519 key material: {error}")))?;
353    let key = SigningKey::from_bytes(&bytes);
354    bytes.fill(0);
355    let der = key
356        .to_pkcs8_der()
357        .map_err(|error| AuthError::Storage(format!("encode signing key DER: {error}")))?;
358    std::fs::write(path, der.as_bytes()).map_err(|error| {
359        AuthError::Storage(format!("write signing key `{}`: {error}", path.display()))
360    })?;
361    set_restrictive_permissions(path)?;
362    Ok(key)
363}
364
365#[cfg(test)]
366mod tests {
367    use jsonwebtoken::{Algorithm, decode_header};
368
369    use super::{AccessClaims, SigningKeys, previous_key_path};
370
371    #[test]
372    fn generated_key_is_reused_on_second_load() {
373        let dir = tempfile::tempdir().unwrap();
374        let path = dir.path().join("auth-jwt.key");
375        let first = SigningKeys::load_or_create(&path).unwrap();
376        let second = SigningKeys::load_or_create(&path).unwrap();
377        assert_eq!(first.key_id, second.key_id);
378    }
379
380    #[cfg(unix)]
381    #[test]
382    fn signing_key_refuses_world_readable_permissions() {
383        use std::os::unix::fs::PermissionsExt;
384
385        let dir = tempfile::tempdir().unwrap();
386        let path = dir.path().join("auth-jwt.key");
387        std::fs::write(&path, "bad").unwrap();
388        std::fs::set_permissions(&path, PermissionsExt::from_mode(0o644)).unwrap();
389        let err = SigningKeys::load_or_create(&path).unwrap_err();
390        assert!(err.to_string().contains("permissions"));
391    }
392
393    #[test]
394    #[allow(deprecated)]
395    fn minted_access_token_round_trips_and_contains_kid() {
396        let signer = test_signer();
397        let claims = sample_claims();
398        let token = signer.issue_access_token(&claims).unwrap();
399        let claims = signer
400            .validate_access_token(&token, "https://lab.example.com")
401            .unwrap();
402        assert_eq!(claims.aud, "https://lab.example.com");
403        assert!(!claims.jti.is_empty());
404        assert!(decode_header(&token).unwrap().kid.is_some());
405    }
406
407    #[test]
408    fn minted_token_uses_eddsa_algorithm() {
409        let signer = test_signer();
410        let token = signer.issue_access_token(&sample_claims()).unwrap();
411        assert_eq!(decode_header(&token).unwrap().alg, Algorithm::EdDSA);
412    }
413
414    #[test]
415    fn jwks_advertises_okp_ed25519_key() {
416        let signer = test_signer();
417        let jwk = &signer.jwks().keys[0];
418        assert_eq!(jwk.kty, "OKP");
419        assert_eq!(jwk.crv, "Ed25519");
420        assert_eq!(jwk.alg, "EdDSA");
421        assert_eq!(jwk.kid, signer.key_id);
422        assert!(!jwk.x.is_empty());
423    }
424
425    #[test]
426    #[allow(deprecated)]
427    fn wrong_audience_is_rejected() {
428        let signer = test_signer();
429        let claims = sample_claims();
430        let token = signer.issue_access_token(&claims).unwrap();
431        let result = signer.validate_access_token(&token, "https://other.example.com");
432        assert!(
433            result.is_err(),
434            "token with wrong audience must be rejected"
435        );
436    }
437
438    #[test]
439    fn validate_with_issuer_accepts_matching_issuer() {
440        let signer = test_signer();
441        let claims = sample_claims();
442        let token = signer.issue_access_token(&claims).unwrap();
443        let decoded = signer
444            .validate_access_token_with_issuer(
445                &token,
446                "https://lab.example.com",
447                "https://lab.example.com",
448            )
449            .expect("token with matching issuer must validate");
450        assert_eq!(decoded.iss, "https://lab.example.com");
451    }
452
453    #[test]
454    fn validate_with_issuer_rejects_wrong_issuer_via_validation_struct() {
455        // Locked decision: issuer enforcement uses Validation::set_issuer
456        // BEFORE decode (so jsonwebtoken rejects up-front), not a manual
457        // post-decode `claims.iss != expected` comparison.
458        let signer = test_signer();
459        let claims = sample_claims();
460        let token = signer.issue_access_token(&claims).unwrap();
461        let result = signer.validate_access_token_with_issuer(
462            &token,
463            "https://lab.example.com",
464            "https://attacker.example.com",
465        );
466        assert!(
467            result.is_err(),
468            "token signed by us but with wrong expected issuer must be rejected"
469        );
470    }
471
472    #[test]
473    fn token_from_a_foreign_key_is_rejected() {
474        let signer = test_signer();
475        let stranger = test_signer();
476        let token = stranger.issue_access_token(&sample_claims()).unwrap();
477        let result = signer.validate_access_token_with_issuer(
478            &token,
479            "https://lab.example.com",
480            "https://lab.example.com",
481        );
482        assert!(
483            result.is_err(),
484            "token signed by a different key (unknown kid) must be rejected"
485        );
486    }
487
488    #[cfg(unix)]
489    #[test]
490    fn legacy_rsa_key_is_quarantined_and_replaced() {
491        use std::os::unix::fs::PermissionsExt;
492
493        let dir = tempfile::tempdir().unwrap();
494        let path = dir.path().join("auth-jwt.key");
495        // A pre-migration RSA PKCS#8 PEM file: valid old content, but not an
496        // Ed25519 PKCS#8 DER key, so it must be quarantined rather than used.
497        std::fs::write(
498            &path,
499            "-----BEGIN PRIVATE KEY-----\nMIIlegacyrsakeymaterial==\n-----END PRIVATE KEY-----\n",
500        )
501        .unwrap();
502        std::fs::set_permissions(&path, PermissionsExt::from_mode(0o600)).unwrap();
503
504        let signer = SigningKeys::load_or_create(&path).unwrap();
505
506        // A retired sibling captured the old key, and the new active key is a
507        // working Ed25519 key.
508        let retired: Vec<_> = std::fs::read_dir(dir.path())
509            .unwrap()
510            .filter_map(Result::ok)
511            .filter(|entry| entry.file_name().to_string_lossy().contains(".retired-"))
512            .collect();
513        assert_eq!(retired.len(), 1, "legacy key should be quarantined once");
514        let token = signer.issue_access_token(&sample_claims()).unwrap();
515        assert_eq!(decode_header(&token).unwrap().alg, Algorithm::EdDSA);
516    }
517
518    #[test]
519    fn rotate_keeps_previous_key_valid_during_overlap() {
520        let dir = tempfile::tempdir().unwrap();
521        let path = dir.path().join("auth-jwt.key");
522        let before = SigningKeys::load_or_create(&path).unwrap();
523        let old_token = before.issue_access_token(&sample_claims()).unwrap();
524
525        let after = SigningKeys::rotate(&path).unwrap();
526
527        // New active key differs, and the JWKS now advertises both keys.
528        assert_ne!(after.key_id, before.key_id);
529        assert_eq!(after.jwks().keys.len(), 2);
530        assert!(previous_key_path(&path).exists());
531
532        // A token minted by the pre-rotation key still validates against the
533        // rotated key set (overlap window)...
534        after
535            .validate_access_token_with_issuer(
536                &old_token,
537                "https://lab.example.com",
538                "https://lab.example.com",
539            )
540            .expect("pre-rotation token must remain valid during overlap");
541
542        // ...and freshly minted tokens validate too.
543        let new_token = after.issue_access_token(&sample_claims()).unwrap();
544        after
545            .validate_access_token_with_issuer(
546                &new_token,
547                "https://lab.example.com",
548                "https://lab.example.com",
549            )
550            .expect("post-rotation token must validate");
551    }
552
553    #[test]
554    fn reload_after_rotate_still_carries_both_keys() {
555        let dir = tempfile::tempdir().unwrap();
556        let path = dir.path().join("auth-jwt.key");
557        let _before = SigningKeys::load_or_create(&path).unwrap();
558        let rotated = SigningKeys::rotate(&path).unwrap();
559        let reloaded = SigningKeys::load_or_create(&path).unwrap();
560        assert_eq!(reloaded.key_id, rotated.key_id);
561        assert_eq!(
562            reloaded.jwks().keys.len(),
563            2,
564            "a restart after rotation must reload the overlap key"
565        );
566    }
567
568    fn test_signer() -> SigningKeys {
569        let dir = tempfile::tempdir().unwrap();
570        let path = dir.path().join("auth-jwt.key");
571        SigningKeys::load_or_create(&path).unwrap()
572    }
573
574    fn sample_claims() -> AccessClaims {
575        AccessClaims {
576            iss: "https://lab.example.com".to_string(),
577            sub: "google-user".to_string(),
578            aud: "https://lab.example.com".to_string(),
579            exp: 4_102_444_800,
580            iat: 1_700_000_000,
581            jti: "test-jti".to_string(),
582            scope: "lab".to_string(),
583            azp: "client".to_string(),
584        }
585    }
586}