1use 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
11pub 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 #[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}