Skip to main content

zerodds_bridge_security/
auth.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! §7.2 auth modes — `none|bearer|jwt|mtls|sasl`.
5//!
6//! Entry point: [`AuthMode::validate`] → [`AuthSubject`] or
7//! [`AuthError`]. In each bridge, the call is hooked into the relevant
8//! connection phase:
9//!
10//! * HTTP-based (ws/grpc): after reading the request headers.
11//! * MQTT: during CONNECT packet evaluation.
12//! * AMQP: after SASL-PLAIN negotiation.
13//! * CORBA: from CSIv2 token parsing or the TLS layer (mtls).
14
15use std::collections::HashMap;
16
17/// Auth subject — passed to subsequent ACL checks (`§7.3`) after a
18/// successful [`AuthMode::validate`].
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct AuthSubject {
21    /// Stable Identity (e.g. JWT `sub`, Bearer-Mapping, mTLS-CN, SASL-User).
22    pub name: String,
23    /// Group memberships (e.g. JWT `groups` claim).
24    pub groups: Vec<String>,
25    /// Free-form claims (any string value).
26    pub claims: HashMap<String, String>,
27    /// mTLS: `mtls:<SHA-256 of the client certificate, hex>`. An ACL entry
28    /// may name it instead of the subject DN in [`Self::name`], to pin one
29    /// certificate.
30    pub fingerprint: Option<String>,
31}
32
33impl AuthSubject {
34    /// Anonymous subject for `AuthMode::None`.
35    #[must_use]
36    pub fn anonymous() -> Self {
37        Self::new("anonymous")
38    }
39
40    /// Plain constructor.
41    #[must_use]
42    pub fn new(name: impl Into<String>) -> Self {
43        Self {
44            name: name.into(),
45            groups: Vec::new(),
46            claims: HashMap::new(),
47            fingerprint: None,
48        }
49    }
50
51    /// Fluent add group.
52    #[must_use]
53    pub fn with_group(mut self, g: impl Into<String>) -> Self {
54        self.groups.push(g.into());
55        self
56    }
57
58    /// Fluent add claim.
59    #[must_use]
60    pub fn with_claim(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
61        self.claims.insert(k.into(), v.into());
62        self
63    }
64}
65
66/// Error during auth validation.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum AuthError {
69    /// Header / frame is entirely missing (e.g. no `Authorization`).
70    MissingCredentials,
71    /// Header format invalid (e.g. `Authorization` without `Bearer `).
72    MalformedCredentials(String),
73    /// Token not accepted (bearer mismatch / JWT signature fail / ...).
74    Rejected(String),
75    /// Configuration broken (e.g. JWT public key cannot be loaded).
76    Misconfigured(String),
77}
78
79impl core::fmt::Display for AuthError {
80    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
81        match self {
82            Self::MissingCredentials => f.write_str("missing credentials"),
83            Self::MalformedCredentials(m) => write!(f, "malformed credentials: {m}"),
84            Self::Rejected(m) => write!(f, "credentials rejected: {m}"),
85            Self::Misconfigured(m) => write!(f, "auth misconfigured: {m}"),
86        }
87    }
88}
89
90impl std::error::Error for AuthError {}
91
92/// Auth mode per daemon (CLI `--auth-mode <MODE>`).
93#[derive(Debug, Clone)]
94pub enum AuthMode {
95    /// No auth. Subject = "anonymous".
96    None,
97    /// Bearer token comparison (HTTP header `Authorization: Bearer …`).
98    /// Multi-token via map `token → subject`.
99    Bearer {
100        /// Mapping from the token string to the resulting subject.
101        tokens: HashMap<String, AuthSubject>,
102    },
103    /// JWT — RS256 signature validation against an RSA public key (DER
104    /// bytes as from `RsaPublicKey::to_pkcs1_der`).
105    Jwt {
106        /// PKCS#1 DER of the RSA public key.
107        pkcs1_pubkey_der: Vec<u8>,
108        /// Optional issuer match (`iss` claim).
109        expected_issuer: Option<String>,
110        /// Optional audience match (`aud` claim, a string or an array
111        /// that has to contain it; RFC 7519 §4.1.3).
112        expected_audience: Option<String>,
113        /// Optional `(claim, value)` the token has to carry. The claim
114        /// matches when it equals the value or, as a space-separated list
115        /// such as OAuth `scope` (RFC 8693 §4.2), contains it.
116        required_claim: Option<(String, String)>,
117    },
118    /// mTLS — identity is supplied by the TLS layer (client cert), not
119    /// by this function. [`AuthMode::validate`] with `Mtls` expects
120    /// `presented_subject = Some(...)`.
121    Mtls,
122    /// SASL-PLAIN — `username\0username\0password` frame, with map
123    /// `username → password`.
124    SaslPlain {
125        /// Mapping `user → password`.
126        users: HashMap<String, String>,
127    },
128}
129
130/// What the caller (daemon) supplies for `validate`.
131#[derive(Debug, Clone, Default)]
132pub struct AuthInput<'a> {
133    /// Bearer/JWT: content of the `Authorization` header (complete).
134    pub authorization_header: Option<&'a str>,
135    /// SASL-PLAIN: raw frame bytes (`user\0user\0pass`).
136    pub sasl_plain_blob: Option<&'a [u8]>,
137    /// mTLS: subject already validated by the rustls connection (e.g.
138    /// cert CN). If mTLS but no cert was presented: `None`.
139    pub mtls_subject: Option<AuthSubject>,
140}
141
142impl AuthMode {
143    /// Validate. Returns the subject or an [`AuthError`].
144    ///
145    /// # Errors
146    /// [`AuthError`] with sub-variant.
147    pub fn validate(&self, input: &AuthInput<'_>) -> Result<AuthSubject, AuthError> {
148        match self {
149            Self::None => Ok(AuthSubject::anonymous()),
150            Self::Bearer { tokens } => {
151                let hdr = input
152                    .authorization_header
153                    .ok_or(AuthError::MissingCredentials)?;
154                let token = strip_bearer(hdr)?;
155                tokens
156                    .get(token)
157                    .cloned()
158                    .ok_or_else(|| AuthError::Rejected("unknown bearer token".to_string()))
159            }
160            Self::Jwt {
161                pkcs1_pubkey_der,
162                expected_issuer,
163                expected_audience,
164                required_claim,
165            } => {
166                let hdr = input
167                    .authorization_header
168                    .ok_or(AuthError::MissingCredentials)?;
169                let token = strip_bearer(hdr)?;
170                validate_jwt_rs256(
171                    token,
172                    pkcs1_pubkey_der,
173                    &JwtChecks {
174                        issuer: expected_issuer.as_deref(),
175                        audience: expected_audience.as_deref(),
176                        required_claim: required_claim
177                            .as_ref()
178                            .map(|(k, v)| (k.as_str(), v.as_str())),
179                        now: unix_now(),
180                    },
181                )
182            }
183            Self::Mtls => input
184                .mtls_subject
185                .clone()
186                .ok_or_else(|| AuthError::Rejected("mTLS expected client cert".to_string())),
187            Self::SaslPlain { users } => {
188                let blob = input.sasl_plain_blob.ok_or(AuthError::MissingCredentials)?;
189                let (user, pass) = parse_sasl_plain(blob)?;
190                let stored = users
191                    .get(user)
192                    .ok_or_else(|| AuthError::Rejected("unknown user".to_string()))?;
193                if stored == pass {
194                    Ok(AuthSubject::new(user))
195                } else {
196                    Err(AuthError::Rejected("password mismatch".to_string()))
197                }
198            }
199        }
200    }
201}
202
203fn strip_bearer(hdr: &str) -> Result<&str, AuthError> {
204    let trimmed = hdr.trim();
205    let prefix = "Bearer ";
206    if trimmed.len() < prefix.len()
207        || !trimmed
208            .get(..prefix.len())
209            .is_some_and(|p| p.eq_ignore_ascii_case(prefix))
210    {
211        return Err(AuthError::MalformedCredentials(
212            "expected `Bearer …`".to_string(),
213        ));
214    }
215    Ok(trimmed[prefix.len()..].trim())
216}
217
218fn parse_sasl_plain(blob: &[u8]) -> Result<(&str, &str), AuthError> {
219    // Format: `[authzid]\0authcid\0passwd` (RFC 4616).
220    let mut parts = blob.splitn(3, |b| *b == 0);
221    let _authzid = parts
222        .next()
223        .ok_or(AuthError::MalformedCredentials("sasl-plain empty".into()))?;
224    let authcid = parts
225        .next()
226        .ok_or(AuthError::MalformedCredentials("sasl-plain no user".into()))?;
227    let passwd = parts
228        .next()
229        .ok_or(AuthError::MalformedCredentials("sasl-plain no pass".into()))?;
230    let user = core::str::from_utf8(authcid)
231        .map_err(|_| AuthError::MalformedCredentials("sasl-plain user utf8".into()))?;
232    let pass = core::str::from_utf8(passwd)
233        .map_err(|_| AuthError::MalformedCredentials("sasl-plain pass utf8".into()))?;
234    if user.is_empty() {
235        return Err(AuthError::MalformedCredentials(
236            "sasl-plain empty user".into(),
237        ));
238    }
239    Ok((user, pass))
240}
241
242// ---------- JWT RS256 ----------------------------------------------------
243
244/// Clock skew accepted on `exp` and `nbf`, in seconds. RFC 7519 §4.1.4
245/// allows "some small leeway"; 60 s is the default of the `jsonwebtoken`
246/// crate's `Validation`.
247pub const JWT_CLOCK_LEEWAY_SECS: u64 = 60;
248
249/// The claim checks after the signature.
250struct JwtChecks<'a> {
251    issuer: Option<&'a str>,
252    audience: Option<&'a str>,
253    required_claim: Option<(&'a str, &'a str)>,
254    /// Seconds since the epoch.
255    now: u64,
256}
257
258fn unix_now() -> u64 {
259    std::time::SystemTime::now()
260        .duration_since(std::time::UNIX_EPOCH)
261        .map_or(0, |d| d.as_secs())
262}
263
264/// A NumericDate claim (RFC 7519 §2): seconds, possibly fractional.
265fn numeric_date(payload: &str, key: &str) -> Result<Option<u64>, AuthError> {
266    let Some(raw) = json_field(payload, key) else {
267        return Ok(None);
268    };
269    let whole = raw.split('.').next().unwrap_or("");
270    whole
271        .parse::<u64>()
272        .map(Some)
273        .map_err(|_| AuthError::MalformedCredentials(format!("jwt: {key} is not a NumericDate")))
274}
275
276fn validate_jwt_rs256(
277    token: &str,
278    pkcs1_pubkey_der: &[u8],
279    checks: &JwtChecks<'_>,
280) -> Result<AuthSubject, AuthError> {
281    use base64::Engine as _;
282    let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
283
284    let mut parts = token.split('.');
285    let h_b64 = parts
286        .next()
287        .ok_or_else(|| AuthError::MalformedCredentials("jwt: no header".into()))?;
288    let p_b64 = parts
289        .next()
290        .ok_or_else(|| AuthError::MalformedCredentials("jwt: no payload".into()))?;
291    let s_b64 = parts
292        .next()
293        .ok_or_else(|| AuthError::MalformedCredentials("jwt: no sig".into()))?;
294    if parts.next().is_some() {
295        return Err(AuthError::MalformedCredentials(
296            "jwt: too many segments".into(),
297        ));
298    }
299
300    let header_bytes = engine
301        .decode(h_b64)
302        .map_err(|e| AuthError::MalformedCredentials(format!("jwt header b64: {e}")))?;
303    let payload_bytes = engine
304        .decode(p_b64)
305        .map_err(|e| AuthError::MalformedCredentials(format!("jwt payload b64: {e}")))?;
306    let sig_bytes = engine
307        .decode(s_b64)
308        .map_err(|e| AuthError::MalformedCredentials(format!("jwt sig b64: {e}")))?;
309
310    // Header must say alg=RS256.
311    let header_str = core::str::from_utf8(&header_bytes)
312        .map_err(|_| AuthError::MalformedCredentials("jwt header utf8".into()))?;
313    if !json_field_eq(header_str, "alg", "RS256") {
314        return Err(AuthError::Rejected("jwt: alg must be RS256".into()));
315    }
316
317    // Verify the signature over `<h_b64>.<p_b64>` with RSA-PKCS#1-v1.5-SHA256.
318    let signed = {
319        let mut v = Vec::with_capacity(h_b64.len() + 1 + p_b64.len());
320        v.extend_from_slice(h_b64.as_bytes());
321        v.push(b'.');
322        v.extend_from_slice(p_b64.as_bytes());
323        v
324    };
325    let pubkey = crate::backend::signature::UnparsedPublicKey::new(
326        &crate::backend::signature::RSA_PKCS1_2048_8192_SHA256,
327        pkcs1_pubkey_der,
328    );
329    pubkey
330        .verify(&signed, &sig_bytes)
331        .map_err(|_| AuthError::Rejected("jwt: signature invalid".into()))?;
332
333    // Extract payload claims.
334    let payload_str = core::str::from_utf8(&payload_bytes)
335        .map_err(|_| AuthError::MalformedCredentials("jwt payload utf8".into()))?;
336    let sub = json_field(payload_str, "sub")
337        .ok_or_else(|| AuthError::Rejected("jwt: no sub claim".into()))?;
338
339    // RFC 7519 §4.1.4 / §4.1.5: a token past `exp` or before `nbf` is
340    // not accepted. Both are optional; a present one is enforced.
341    if let Some(exp) = numeric_date(payload_str, "exp")? {
342        if checks.now >= exp.saturating_add(JWT_CLOCK_LEEWAY_SECS) {
343            return Err(AuthError::Rejected("jwt: expired".into()));
344        }
345    }
346    if let Some(nbf) = numeric_date(payload_str, "nbf")? {
347        if checks.now.saturating_add(JWT_CLOCK_LEEWAY_SECS) < nbf {
348            return Err(AuthError::Rejected("jwt: not yet valid".into()));
349        }
350    }
351
352    if let Some(expected) = checks.issuer {
353        let iss = json_field(payload_str, "iss")
354            .ok_or_else(|| AuthError::Rejected("jwt: no iss claim".into()))?;
355        if iss != expected {
356            return Err(AuthError::Rejected(format!("jwt: iss != {expected}")));
357        }
358    }
359    if let Some(expected) = checks.audience {
360        let matches = match json_array(payload_str, "aud") {
361            Some(list) => list.iter().any(|a| a == expected),
362            None => json_field(payload_str, "aud").is_some_and(|a| a == expected),
363        };
364        if !matches {
365            return Err(AuthError::Rejected(format!("jwt: aud lacks {expected}")));
366        }
367    }
368    if let Some((claim, value)) = checks.required_claim {
369        let matches = match json_array(payload_str, claim) {
370            Some(list) => list.iter().any(|v| v == value),
371            None => json_field(payload_str, claim)
372                .is_some_and(|v| v == value || v.split(' ').any(|p| p == value)),
373        };
374        if !matches {
375            return Err(AuthError::Rejected(format!(
376                "jwt: claim {claim} lacks {value}"
377            )));
378        }
379    }
380
381    let mut subj = AuthSubject::new(sub);
382    if let Some(groups_raw) = json_array(payload_str, "groups") {
383        for g in groups_raw {
384            subj.groups.push(g);
385        }
386    }
387    Ok(subj)
388}
389
390/// The PKCS#1 `RSAPublicKey` DER that [`AuthMode::Jwt`] verifies with,
391/// out of a PEM file's text.
392///
393/// Accepts `-----BEGIN RSA PUBLIC KEY-----` (PKCS#1, RFC 8017 §A.1.1) and
394/// `-----BEGIN PUBLIC KEY-----` (X.509 `SubjectPublicKeyInfo`, RFC 5280
395/// §4.1.2.7, as `openssl rsa -pubout` writes it) with the rsaEncryption
396/// algorithm; the latter is unwrapped to the key inside its BIT STRING.
397///
398/// # Errors
399/// A message naming what the text lacks.
400pub fn jwt_public_key_from_pem(pem: &str) -> Result<Vec<u8>, String> {
401    use base64::Engine as _;
402    fn block(pem: &str, label: &str) -> Option<String> {
403        let begin = format!("-----BEGIN {label}-----");
404        let end = format!("-----END {label}-----");
405        let start = pem.find(&begin)? + begin.len();
406        let stop = pem[start..].find(&end)? + start;
407        Some(pem[start..stop].split_whitespace().collect())
408    }
409    let decode = |b64: String| {
410        base64::engine::general_purpose::STANDARD
411            .decode(b64)
412            .map_err(|e| format!("pem base64: {e}"))
413    };
414    if let Some(b64) = block(pem, "RSA PUBLIC KEY") {
415        return decode(b64);
416    }
417    let Some(b64) = block(pem, "PUBLIC KEY") else {
418        return Err("no RSA PUBLIC KEY or PUBLIC KEY block".into());
419    };
420    let spki = decode(b64)?;
421    // SEQUENCE { SEQUENCE { OID rsaEncryption, NULL }, BIT STRING }
422    let (tag, outer, _) = der_tlv(&spki).ok_or("spki: bad outer TLV")?;
423    if tag != 0x30 {
424        return Err("spki: not a SEQUENCE".into());
425    }
426    let (tag, alg, rest) = der_tlv(outer).ok_or("spki: bad algorithm TLV")?;
427    if tag != 0x30 {
428        return Err("spki: no AlgorithmIdentifier".into());
429    }
430    let (tag, oid, _) = der_tlv(alg).ok_or("spki: bad OID TLV")?;
431    const RSA_ENCRYPTION: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01];
432    if tag != 0x06 || oid != RSA_ENCRYPTION {
433        return Err("spki: algorithm is not rsaEncryption".into());
434    }
435    let (tag, bits, _) = der_tlv(rest).ok_or("spki: bad BIT STRING TLV")?;
436    match (tag, bits.split_first()) {
437        (0x03, Some((0, key))) => Ok(key.to_vec()),
438        _ => Err("spki: no BIT STRING with the key".into()),
439    }
440}
441
442/// One DER TLV: tag, contents, what follows.
443fn der_tlv(input: &[u8]) -> Option<(u8, &[u8], &[u8])> {
444    let (&tag, rest) = input.split_first()?;
445    let (&first, rest) = rest.split_first()?;
446    let (len, rest) = if first < 0x80 {
447        (usize::from(first), rest)
448    } else {
449        let n = usize::from(first & 0x7f);
450        if n == 0 || n > 4 || rest.len() < n {
451            return None;
452        }
453        let len = rest[..n]
454            .iter()
455            .fold(0usize, |acc, b| (acc << 8) | usize::from(*b));
456        (len, &rest[n..])
457    };
458    if rest.len() < len {
459        return None;
460    }
461    Some((tag, &rest[..len], &rest[len..]))
462}
463
464/// Mini JSON field extractor for the JWT header and payload maps.
465/// Accepts flat-structured JSON as JWT libraries emit it. Not a full
466/// JSON parser implementation — Spec §7.2 says a JWT lib may use a
467/// compact subset.
468fn json_field(src: &str, key: &str) -> Option<String> {
469    let pat = format!("\"{key}\"");
470    let pos = src.find(&pat)?;
471    let after = &src[pos + pat.len()..];
472    let colon = after.find(':')?;
473    let rest = after[colon + 1..].trim_start();
474    if let Some(stripped) = rest.strip_prefix('"') {
475        let end = stripped.find('"')?;
476        Some(stripped[..end].to_string())
477    } else {
478        // Numeric / bool — take up to the next , or }.
479        let end = rest
480            .find(|c: char| c == ',' || c == '}' || c.is_whitespace())
481            .unwrap_or(rest.len());
482        Some(rest[..end].to_string())
483    }
484}
485
486fn json_field_eq(src: &str, key: &str, expected: &str) -> bool {
487    json_field(src, key).is_some_and(|v| v == expected)
488}
489
490fn json_array(src: &str, key: &str) -> Option<Vec<String>> {
491    let pat = format!("\"{key}\"");
492    let pos = src.find(&pat)?;
493    let after = &src[pos + pat.len()..];
494    let colon = after.find(':')?;
495    let rest = after[colon + 1..].trim_start();
496    let stripped = rest.strip_prefix('[')?;
497    let end = stripped.find(']')?;
498    let inside = &stripped[..end];
499    let mut out = Vec::new();
500    for piece in inside.split(',') {
501        let p = piece.trim().trim_matches('"');
502        if !p.is_empty() {
503            out.push(p.to_string());
504        }
505    }
506    Some(out)
507}
508
509#[cfg(test)]
510#[allow(clippy::expect_used, clippy::unwrap_used)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn none_mode_yields_anonymous() {
516        let m = AuthMode::None;
517        let s = m.validate(&AuthInput::default()).unwrap();
518        assert_eq!(s.name, "anonymous");
519    }
520
521    #[test]
522    fn bearer_valid_token_accepted() {
523        let mut tokens = HashMap::new();
524        tokens.insert("secret123".to_string(), AuthSubject::new("alice"));
525        let m = AuthMode::Bearer { tokens };
526        let s = m
527            .validate(&AuthInput {
528                authorization_header: Some("Bearer secret123"),
529                ..Default::default()
530            })
531            .unwrap();
532        assert_eq!(s.name, "alice");
533    }
534
535    #[test]
536    fn bearer_invalid_token_rejected() {
537        let m = AuthMode::Bearer {
538            tokens: HashMap::new(),
539        };
540        let err = m
541            .validate(&AuthInput {
542                authorization_header: Some("Bearer wrong"),
543                ..Default::default()
544            })
545            .unwrap_err();
546        assert!(matches!(err, AuthError::Rejected(_)));
547    }
548
549    #[test]
550    fn bearer_missing_header_returns_missing() {
551        let m = AuthMode::Bearer {
552            tokens: HashMap::new(),
553        };
554        let err = m.validate(&AuthInput::default()).unwrap_err();
555        assert!(matches!(err, AuthError::MissingCredentials));
556    }
557
558    #[test]
559    fn bearer_malformed_header_returns_malformed() {
560        let m = AuthMode::Bearer {
561            tokens: HashMap::new(),
562        };
563        let err = m
564            .validate(&AuthInput {
565                authorization_header: Some("Basic xx"),
566                ..Default::default()
567            })
568            .unwrap_err();
569        assert!(matches!(err, AuthError::MalformedCredentials(_)));
570    }
571
572    #[test]
573    fn mtls_with_subject_accepted() {
574        let m = AuthMode::Mtls;
575        let s = m
576            .validate(&AuthInput {
577                mtls_subject: Some(AuthSubject::new("CN=alice")),
578                ..Default::default()
579            })
580            .unwrap();
581        assert_eq!(s.name, "CN=alice");
582    }
583
584    #[test]
585    fn mtls_without_subject_rejected() {
586        let m = AuthMode::Mtls;
587        let err = m.validate(&AuthInput::default()).unwrap_err();
588        assert!(matches!(err, AuthError::Rejected(_)));
589    }
590
591    #[test]
592    fn sasl_plain_valid_pair_accepted() {
593        let mut users = HashMap::new();
594        users.insert("alice".to_string(), "wonderland".to_string());
595        let m = AuthMode::SaslPlain { users };
596        let blob = b"\0alice\0wonderland";
597        let s = m
598            .validate(&AuthInput {
599                sasl_plain_blob: Some(blob),
600                ..Default::default()
601            })
602            .unwrap();
603        assert_eq!(s.name, "alice");
604    }
605
606    #[test]
607    fn sasl_plain_wrong_password_rejected() {
608        let mut users = HashMap::new();
609        users.insert("alice".to_string(), "wonderland".to_string());
610        let m = AuthMode::SaslPlain { users };
611        let blob = b"\0alice\0wrong";
612        let err = m
613            .validate(&AuthInput {
614                sasl_plain_blob: Some(blob),
615                ..Default::default()
616            })
617            .unwrap_err();
618        assert!(matches!(err, AuthError::Rejected(_)));
619    }
620
621    #[test]
622    fn json_field_extracts_string() {
623        let s = r#"{"alg":"RS256","typ":"JWT"}"#;
624        assert_eq!(json_field(s, "alg").as_deref(), Some("RS256"));
625    }
626
627    #[test]
628    fn json_array_extracts_groups() {
629        let s = r#"{"sub":"a","groups":["eng","ops"]}"#;
630        let g = json_array(s, "groups").unwrap();
631        assert_eq!(g, vec!["eng".to_string(), "ops".to_string()]);
632    }
633
634    #[test]
635    fn jwt_invalid_signature_rejected() {
636        // Bogus pubkey + bogus token. We only test the reject path — a
637        // fully-signed round-trip test would need an RSA key-gen lib
638        // (rcgen can't do that for JWT). The code path itself is driven
639        // against `crate::backend::signature::UnparsedPublicKey::verify`.
640        let m = AuthMode::Jwt {
641            pkcs1_pubkey_der: vec![0u8; 32],
642            expected_issuer: None,
643            expected_audience: None,
644            required_claim: None,
645        };
646        // base64url("{\"alg\":\"RS256\"}") = "eyJhbGciOiJSUzI1NiJ9"
647        // base64url("{\"sub\":\"a\"}") = "eyJzdWIiOiJhIn0"
648        let token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhIn0.AAAA";
649        let err = m
650            .validate(&AuthInput {
651                authorization_header: Some(&format!("Bearer {token}")),
652                ..Default::default()
653            })
654            .unwrap_err();
655        assert!(matches!(err, AuthError::Rejected(_)));
656    }
657
658    #[test]
659    fn jwt_wrong_alg_rejected() {
660        let m = AuthMode::Jwt {
661            pkcs1_pubkey_der: vec![0u8; 32],
662            expected_issuer: None,
663            expected_audience: None,
664            required_claim: None,
665        };
666        // base64url("{\"alg\":\"HS256\"}") = "eyJhbGciOiJIUzI1NiJ9"
667        let token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhIn0.AAAA";
668        let err = m
669            .validate(&AuthInput {
670                authorization_header: Some(&format!("Bearer {token}")),
671                ..Default::default()
672            })
673            .unwrap_err();
674        assert!(matches!(err, AuthError::Rejected(_)));
675    }
676
677    /// An RSA key from `openssl genpkey`: the private half as PKCS#8 DER
678    /// for signing, the public half as the SPKI PEM `openssl` writes.
679    fn rsa_key() -> (Vec<u8>, String) {
680        use std::process::Command;
681        static SEQ: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
682        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
683        let dir = std::env::temp_dir().join(format!("zd-jwt-{}-{seq}", std::process::id()));
684        std::fs::create_dir_all(&dir).unwrap();
685        let key = dir.join("key.pem");
686        let run = |args: &[&str]| {
687            let out = Command::new("openssl")
688                .args(args)
689                .output()
690                .expect("openssl on PATH: the JWT round trip needs a real RSA key");
691            assert!(out.status.success(), "openssl {args:?}: {out:?}");
692            out.stdout
693        };
694        run(&[
695            "genpkey",
696            "-algorithm",
697            "RSA",
698            "-pkeyopt",
699            "rsa_keygen_bits:2048",
700            "-out",
701            key.to_str().unwrap(),
702        ]);
703        let der = run(&[
704            "pkcs8",
705            "-topk8",
706            "-nocrypt",
707            "-outform",
708            "DER",
709            "-in",
710            key.to_str().unwrap(),
711        ]);
712        let spki = run(&["pkey", "-pubout", "-in", key.to_str().unwrap()]);
713        let _ = std::fs::remove_dir_all(&dir);
714        (der, String::from_utf8(spki).unwrap())
715    }
716
717    fn sign(pkcs8: &[u8], payload: &str) -> String {
718        use crate::backend::{rand, signature};
719        use base64::Engine as _;
720        let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
721        let signed = format!(
722            "{}.{}",
723            b64(br#"{"alg":"RS256","typ":"JWT"}"#),
724            b64(payload.as_bytes())
725        );
726        let pair = signature::RsaKeyPair::from_pkcs8(pkcs8).unwrap();
727        let mut sig = vec![0u8; 256]; // rsa_keygen_bits:2048
728        pair.sign(
729            &signature::RSA_PKCS1_SHA256,
730            &rand::SystemRandom::new(),
731            signed.as_bytes(),
732            &mut sig,
733        )
734        .unwrap();
735        format!("{signed}.{}", b64(&sig))
736    }
737
738    /// A token signed with a real key, checked with its SPKI PEM: the
739    /// signature, `exp`, `nbf`, `iss`, `aud` and a required claim.
740    #[test]
741    fn a_signed_jwt_is_checked_for_time_audience_and_claim() {
742        let (pkcs8, spki_pem) = rsa_key();
743        let mode = AuthMode::Jwt {
744            pkcs1_pubkey_der: jwt_public_key_from_pem(&spki_pem).unwrap(),
745            expected_issuer: Some("zerodds-test".into()),
746            expected_audience: Some("zerodds-grpc".into()),
747            required_claim: Some(("scope".into(), "dds.read".into())),
748        };
749        let now = unix_now();
750        let check = |payload: String| {
751            mode.validate(&AuthInput {
752                authorization_header: Some(&format!("Bearer {}", sign(&pkcs8, &payload))),
753                ..Default::default()
754            })
755        };
756        let token = |exp: u64, nbf: u64, aud: &str, scope: &str| {
757            format!(
758                r#"{{"sub":"alice","iss":"zerodds-test","aud":{aud},"scope":"{scope}","exp":{exp},"nbf":{nbf}}}"#
759            )
760        };
761        let ok = check(token(
762            now + 600,
763            now - 10,
764            r#"["x","zerodds-grpc"]"#,
765            "dds.write dds.read",
766        ));
767        assert_eq!(ok.unwrap().name, "alice");
768        let late = now - JWT_CLOCK_LEEWAY_SECS - 1;
769        for (why, payload) in [
770            (
771                "expired",
772                token(late, now - 900, r#""zerodds-grpc""#, "dds.read"),
773            ),
774            (
775                "not yet valid",
776                token(
777                    now + 900,
778                    now + JWT_CLOCK_LEEWAY_SECS + 60,
779                    r#""zerodds-grpc""#,
780                    "dds.read",
781                ),
782            ),
783            ("aud", token(now + 600, now, r#""other""#, "dds.read")),
784            (
785                "claim",
786                token(now + 600, now, r#""zerodds-grpc""#, "dds.write"),
787            ),
788        ] {
789            let err = check(payload).unwrap_err();
790            assert!(matches!(err, AuthError::Rejected(_)), "{why}: {err:?}");
791        }
792        // A signature by another key is refused.
793        let (other, _) = rsa_key();
794        let forged = format!(
795            "Bearer {}",
796            sign(
797                &other,
798                &token(now + 600, now, r#""zerodds-grpc""#, "dds.read")
799            )
800        );
801        assert!(
802            mode.validate(&AuthInput {
803                authorization_header: Some(&forged),
804                ..Default::default()
805            })
806            .is_err()
807        );
808    }
809
810    #[test]
811    fn a_pkcs1_pem_and_an_spki_pem_yield_the_same_key() {
812        let (_, spki_pem) = rsa_key();
813        let from_spki = jwt_public_key_from_pem(&spki_pem).unwrap();
814        use base64::Engine as _;
815        let pkcs1_pem = format!(
816            "-----BEGIN RSA PUBLIC KEY-----\n{}\n-----END RSA PUBLIC KEY-----\n",
817            base64::engine::general_purpose::STANDARD.encode(&from_spki)
818        );
819        assert_eq!(jwt_public_key_from_pem(&pkcs1_pem).unwrap(), from_spki);
820        assert!(jwt_public_key_from_pem("no key here").is_err());
821    }
822}