wire_e2e_identity/acme/
authz.rs1use base64::Engine;
2use rusty_jwt_tools::prelude::*;
3
4use crate::acme::{chall::AcmeChallengeType, prelude::*};
5
6impl RustyAcme {
7 pub fn new_authz_request(
10 url: &url::Url,
11 account: &AcmeAccount,
12 alg: JwsAlgorithm,
13 kp: &Pem,
14 previous_nonce: String,
15 ) -> RustyAcmeResult<AcmeJws> {
16 let acct_url = account.acct_url()?;
18
19 let payload = None::<serde_json::Value>;
21 let req = AcmeJws::new(alg, previous_nonce, url, Some(&acct_url), payload, kp)?;
22 Ok(req)
23 }
24
25 pub fn new_authz_response(response: serde_json::Value) -> RustyAcmeResult<AcmeAuthz> {
28 let authz = serde_json::from_value::<AcmeAuthz>(response)?;
29
30 authz.verify()?;
31
32 match authz.status {
33 AuthzStatus::Pending => {}
34 AuthzStatus::Invalid => return Err(AcmeAuthzError::Invalid)?,
35 AuthzStatus::Revoked => return Err(AcmeAuthzError::Revoked)?,
36 AuthzStatus::Deactivated => return Err(AcmeAuthzError::Deactivated)?,
37 AuthzStatus::Expired => return Err(AcmeAuthzError::Expired)?,
38 AuthzStatus::Valid => {
39 return Err(RustyAcmeError::ClientImplementationError(
40 "an authorization is not supposed to be valid at this point. \
41 You should only use this method to parse the response of an authorization creation.",
42 ));
43 }
44 }
45 Ok(authz)
46 }
47}
48
49#[derive(Debug, thiserror::Error)]
50pub enum AcmeAuthzError {
51 #[error("This authorization is expired")]
53 Expired,
54 #[error("This authorization is invalid")]
56 Invalid,
57 #[error("The server revoked this authorization")]
59 Revoked,
60 #[error("The client deactivated this authorization")]
62 Deactivated,
63 #[error("The Challenge tokens must be base64 URL strings")]
65 InvalidBase64Token,
66 #[error("The Challenge token must have at least 128 bits of entropy")]
68 InvalidTokenEntropy,
69 #[error("The Challenge type must match the identifier type")]
71 InvalidChallengeType,
72}
73
74#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct AcmeAuthz {
79 pub status: AuthzStatus,
81 #[serde(skip_serializing_if = "Option::is_none", with = "time::serde::rfc3339::option")]
82 pub expires: Option<time::OffsetDateTime>,
84 pub challenges: [AcmeChallenge; 1],
86 pub identifier: AcmeIdentifier,
88}
89
90impl AcmeAuthz {
91 pub fn verify(&self) -> RustyAcmeResult<()> {
92 let [challenge] = &self.challenges;
93
94 if matches!(
95 (&self.identifier, challenge.typ),
96 (AcmeIdentifier::WireappDevice(_), AcmeChallengeType::WireDpop01)
97 | (AcmeIdentifier::WireappUser(_), AcmeChallengeType::WireOidc01)
98 ) {
99 let now = time::OffsetDateTime::now_utc().unix_timestamp();
100
101 let is_expired = self
102 .expires
103 .map(time::OffsetDateTime::unix_timestamp)
104 .is_some_and(|expires| expires < now);
105 if is_expired {
106 return Err(AcmeAuthzError::Expired)?;
107 }
108
109 let token = base64::prelude::BASE64_URL_SAFE_NO_PAD
112 .decode(&challenge.token)
113 .map_err(|_| AcmeAuthzError::InvalidBase64Token)?;
114
115 const RECOMMENDED_TOKEN_ENTROPY: usize = 128 / 8;
118 if token.len() < RECOMMENDED_TOKEN_ENTROPY {
119 return Err(AcmeAuthzError::InvalidTokenEntropy.into());
120 }
121
122 return Ok(());
123 }
124 Err(AcmeAuthzError::InvalidChallengeType)?
125 }
126}
127
128#[cfg(test)]
129impl Default for AcmeAuthz {
130 fn default() -> Self {
131 Self {
132 status: AuthzStatus::Pending,
133 expires: Some(time::OffsetDateTime::now_utc()),
134 identifier: AcmeIdentifier::new_device(),
135 challenges: [AcmeChallenge::new_device()],
136 }
137 }
138}
139
140#[derive(Debug, Copy, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
142#[serde(rename_all = "lowercase")]
143pub enum AuthzStatus {
144 Pending,
145 Invalid,
146 Valid,
147 Revoked,
148 Deactivated,
149 Expired,
150}
151
152#[cfg(test)]
153pub mod tests {
154 use serde_json::json;
155 use wasm_bindgen_test::*;
156
157 use super::*;
158
159 wasm_bindgen_test_configure!(run_in_browser);
160
161 mod json {
162 use super::*;
163
164 #[test]
165 #[wasm_bindgen_test]
166 fn can_deserialize_sample_response() {
167 let rfc_sample = json!({
168 "status": "pending",
169 "expires": "2016-01-02T14:09:30Z",
170 "identifier": {
171 "type": "wireapp-user",
172 "value": "www.example.org"
173 },
174 "challenges": [
175 {
176 "type": "http-01",
177 "url": "https://example.com/acme/chall/prV_B7yEyA4",
178 "token": "DGyRejmCefe7v4NfDGDKfA",
179 "target": "https://example.com/target"
180 }
181 ]
182 });
183 assert!(serde_json::from_value::<AcmeAuthz>(rfc_sample).is_ok());
184 }
185 }
186
187 mod verify {
188 use super::*;
189
190 #[test]
191 #[wasm_bindgen_test]
192 fn should_succeed_when_valid() {
193 let tomorrow = time::OffsetDateTime::now_utc() + time::Duration::days(1);
194 let order = AcmeAuthz {
195 expires: Some(tomorrow),
196 ..Default::default()
197 };
198 assert!(order.verify().is_ok());
199 }
200
201 #[test]
202 #[wasm_bindgen_test]
203 fn should_fail_when_expires_in_past() {
204 let yesterday = time::OffsetDateTime::now_utc() - time::Duration::days(1);
205 let order = AcmeAuthz {
206 expires: Some(yesterday),
207 ..Default::default()
208 };
209 assert!(matches!(
210 order.verify().unwrap_err(),
211 RustyAcmeError::AuthzError(AcmeAuthzError::Expired)
212 ));
213 }
214
215 #[test]
216 #[wasm_bindgen_test]
217 fn should_fail_when_challenge_type_mismatches_identifier_type() {
218 let tomorrow = time::OffsetDateTime::now_utc() + time::Duration::days(1);
219 let order = AcmeAuthz {
220 expires: Some(tomorrow),
221 identifier: AcmeIdentifier::new_user(),
222 challenges: [AcmeChallenge::new_device()],
223 ..Default::default()
224 };
225 assert!(matches!(
226 order.verify().unwrap_err(),
227 RustyAcmeError::AuthzError(AcmeAuthzError::InvalidChallengeType)
228 ));
229 let order = AcmeAuthz {
230 expires: Some(tomorrow),
231 identifier: AcmeIdentifier::new_device(),
232 challenges: [AcmeChallenge::new_user()],
233 ..Default::default()
234 };
235 assert!(matches!(
236 order.verify().unwrap_err(),
237 RustyAcmeError::AuthzError(AcmeAuthzError::InvalidChallengeType)
238 ));
239 }
240 }
241}