Skip to main content

arcp_runtime/auth/
jwt.rs

1//! `signed_jwt` authentication scheme (RFC §8.2).
2
3use async_trait::async_trait;
4use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
5use serde::Deserialize;
6
7use arcp_core::auth::{AuthOutcome, Authenticator};
8use arcp_core::error::ARCPError;
9use arcp_core::messages::{AuthScheme, Capabilities, ClientIdentity, Credentials};
10
11/// Authenticator for `signed_jwt`.
12///
13/// Carries an HMAC-SHA256 secret and the audience this runtime expects.
14/// On successful validation the principal is the JWT's `sub` claim.
15pub struct SignedJwtAuthenticator {
16    decoding_key: DecodingKey,
17    validation: Validation,
18}
19
20impl std::fmt::Debug for SignedJwtAuthenticator {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        f.debug_struct("SignedJwtAuthenticator")
23            .finish_non_exhaustive()
24    }
25}
26
27impl SignedJwtAuthenticator {
28    /// Construct an HS256 authenticator with `secret` and the audience the
29    /// runtime expects to see in the `aud` claim.
30    #[must_use]
31    pub fn hs256(secret: &[u8], audience: impl Into<String>) -> Self {
32        let mut validation = Validation::new(Algorithm::HS256);
33        validation.set_audience(&[audience.into()]);
34        Self {
35            decoding_key: DecodingKey::from_secret(secret),
36            validation,
37        }
38    }
39}
40
41#[derive(Debug, Deserialize)]
42struct Claims {
43    sub: String,
44}
45
46#[async_trait]
47impl Authenticator for SignedJwtAuthenticator {
48    fn scheme(&self) -> AuthScheme {
49        AuthScheme::SignedJwt
50    }
51
52    async fn authenticate(
53        &self,
54        creds: &Credentials,
55        _client: &ClientIdentity,
56        _negotiated: &Capabilities,
57    ) -> Result<AuthOutcome, ARCPError> {
58        let Some(token) = &creds.token else {
59            return Ok(AuthOutcome::Reject {
60                reason: "signed_jwt scheme requires a token".into(),
61            });
62        };
63        match decode::<Claims>(token, &self.decoding_key, &self.validation) {
64            Ok(data) => Ok(AuthOutcome::Accept {
65                principal: data.claims.sub,
66            }),
67            Err(err) => Ok(AuthOutcome::Reject {
68                reason: format!("jwt validation failed: {err}"),
69            }),
70        }
71    }
72}
73
74#[cfg(test)]
75#[allow(
76    clippy::expect_used,
77    clippy::unwrap_used,
78    clippy::panic,
79    clippy::missing_panics_doc
80)]
81mod tests {
82    use jsonwebtoken::{encode, EncodingKey, Header};
83    use serde::Serialize;
84
85    use super::*;
86    use arcp_core::messages::{AuthScheme, Capabilities, ClientIdentity, Credentials};
87
88    #[derive(Serialize)]
89    struct Mint<'a> {
90        sub: &'a str,
91        aud: &'a str,
92        exp: usize,
93    }
94
95    fn ident() -> ClientIdentity {
96        ClientIdentity {
97            kind: "test".into(),
98            version: "0".into(),
99            fingerprint: None,
100            principal: None,
101        }
102    }
103
104    fn mint(secret: &[u8], sub: &str, aud: &str) -> String {
105        let claims = Mint {
106            sub,
107            aud,
108            exp: 9_999_999_999,
109        };
110        encode(
111            &Header::default(),
112            &claims,
113            &EncodingKey::from_secret(secret),
114        )
115        .expect("encode")
116    }
117
118    #[tokio::test]
119    async fn valid_jwt_accepts_with_sub_as_principal() {
120        let secret = b"shared-test-secret-9876543210";
121        let auth = SignedJwtAuthenticator::hs256(secret, "arcp-test-runtime");
122        let token = mint(secret, "alice@example.com", "arcp-test-runtime");
123        let creds = Credentials {
124            scheme: AuthScheme::SignedJwt,
125            token: Some(token),
126        };
127        let outcome = auth
128            .authenticate(&creds, &ident(), &Capabilities::default())
129            .await
130            .expect("auth call ok");
131        match outcome {
132            AuthOutcome::Accept { principal } => {
133                assert_eq!(principal, "alice@example.com");
134            }
135            other => panic!("expected Accept, got {other:?}"),
136        }
137    }
138
139    #[tokio::test]
140    async fn jwt_for_wrong_audience_is_rejected() {
141        let secret = b"shared-test-secret-9876543210";
142        let auth = SignedJwtAuthenticator::hs256(secret, "arcp-test-runtime");
143        let token = mint(secret, "alice", "some-other-audience");
144        let creds = Credentials {
145            scheme: AuthScheme::SignedJwt,
146            token: Some(token),
147        };
148        let outcome = auth
149            .authenticate(&creds, &ident(), &Capabilities::default())
150            .await
151            .expect("auth call ok");
152        assert!(matches!(outcome, AuthOutcome::Reject { .. }));
153    }
154
155    #[tokio::test]
156    async fn jwt_with_wrong_secret_is_rejected() {
157        let auth = SignedJwtAuthenticator::hs256(b"server-secret", "arcp-test-runtime");
158        let token = mint(b"attacker-secret", "alice", "arcp-test-runtime");
159        let creds = Credentials {
160            scheme: AuthScheme::SignedJwt,
161            token: Some(token),
162        };
163        let outcome = auth
164            .authenticate(&creds, &ident(), &Capabilities::default())
165            .await
166            .expect("auth call ok");
167        assert!(matches!(outcome, AuthOutcome::Reject { .. }));
168    }
169
170    #[tokio::test]
171    async fn missing_token_is_rejected() {
172        let auth = SignedJwtAuthenticator::hs256(b"x", "rt");
173        let creds = Credentials {
174            scheme: AuthScheme::SignedJwt,
175            token: None,
176        };
177        let outcome = auth
178            .authenticate(&creds, &ident(), &Capabilities::default())
179            .await
180            .expect("auth call ok");
181        assert!(matches!(outcome, AuthOutcome::Reject { .. }));
182    }
183
184    #[test]
185    fn scheme_reports_signed_jwt() {
186        let auth = SignedJwtAuthenticator::hs256(b"x", "rt");
187        assert!(matches!(auth.scheme(), AuthScheme::SignedJwt));
188    }
189}