1use alloc::format;
17use alloc::string::String;
18use alloc::vec::Vec;
19
20pub const WSSE_NS: &str =
22 "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd";
23pub const WSU_NS: &str =
25 "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd";
26
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct UsernameToken {
30 pub username: String,
32 pub password: String,
34 pub password_type: PasswordType,
36 pub nonce: Option<String>,
38 pub created: Option<String>,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum PasswordType {
45 #[default]
47 Text,
48 Digest,
51}
52
53impl PasswordType {
54 #[must_use]
56 pub fn type_uri(self) -> &'static str {
57 match self {
58 Self::Text => {
59 "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText"
60 }
61 Self::Digest => {
62 "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest"
63 }
64 }
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct X509Token {
71 pub cert_b64: String,
73 pub encoding_type: String,
75 pub value_type: String,
77}
78
79impl Default for X509Token {
80 fn default() -> Self {
81 Self {
82 cert_b64: String::new(),
83 encoding_type: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary".into(),
84 value_type: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3".into(),
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Default)]
91pub struct Timestamp {
92 pub created: String,
94 pub expires: Option<String>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Default)]
100pub struct SecurityHeader {
101 pub usernames: Vec<UsernameToken>,
103 pub x509: Vec<X509Token>,
105 pub timestamp: Option<Timestamp>,
107 pub must_understand: bool,
109}
110
111impl SecurityHeader {
112 #[must_use]
115 pub fn to_xml(&self) -> String {
116 let mu = if self.must_understand {
117 " soap:mustUnderstand=\"1\""
118 } else {
119 ""
120 };
121 let mut out =
122 format!("<wsse:Security xmlns:wsse=\"{WSSE_NS}\" xmlns:wsu=\"{WSU_NS}\"{mu}>");
123 if let Some(ts) = &self.timestamp {
124 out.push_str("<wsu:Timestamp>");
125 out.push_str(&format!("<wsu:Created>{}</wsu:Created>", ts.created));
126 if let Some(exp) = &ts.expires {
127 out.push_str(&format!("<wsu:Expires>{exp}</wsu:Expires>"));
128 }
129 out.push_str("</wsu:Timestamp>");
130 }
131 for u in &self.usernames {
132 out.push_str("<wsse:UsernameToken>");
133 out.push_str(&format!("<wsse:Username>{}</wsse:Username>", u.username));
134 out.push_str(&format!(
135 "<wsse:Password Type=\"{}\">{}</wsse:Password>",
136 u.password_type.type_uri(),
137 xml_escape(&u.password)
138 ));
139 if let Some(n) = &u.nonce {
140 out.push_str(&format!("<wsse:Nonce>{n}</wsse:Nonce>"));
141 }
142 if let Some(c) = &u.created {
143 out.push_str(&format!("<wsu:Created>{c}</wsu:Created>"));
144 }
145 out.push_str("</wsse:UsernameToken>");
146 }
147 for x in &self.x509 {
148 out.push_str(&format!(
149 "<wsse:BinarySecurityToken EncodingType=\"{}\" ValueType=\"{}\">{}</wsse:BinarySecurityToken>",
150 x.encoding_type, x.value_type, x.cert_b64
151 ));
152 }
153 out.push_str("</wsse:Security>");
154 out
155 }
156}
157
158fn xml_escape(s: &str) -> String {
159 s.replace('&', "&")
160 .replace('<', "<")
161 .replace('>', ">")
162}
163
164#[cfg(test)]
165#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn empty_header_still_emits_security_element() {
171 let h = SecurityHeader::default();
172 let xml = h.to_xml();
173 assert!(xml.starts_with("<wsse:Security"));
174 assert!(xml.ends_with("</wsse:Security>"));
175 }
176
177 #[test]
178 fn username_token_text_password_emits_correct_type() {
179 let mut h = SecurityHeader::default();
180 h.usernames.push(UsernameToken {
181 username: "alice".into(),
182 password: "secret".into(),
183 password_type: PasswordType::Text,
184 ..UsernameToken::default()
185 });
186 let xml = h.to_xml();
187 assert!(xml.contains("<wsse:Username>alice</wsse:Username>"));
188 assert!(xml.contains("PasswordText"));
189 assert!(xml.contains(">secret<"));
190 }
191
192 #[test]
193 fn username_token_digest_uses_digest_uri() {
194 let mut h = SecurityHeader::default();
195 h.usernames.push(UsernameToken {
196 username: "alice".into(),
197 password: "abc=".into(),
198 password_type: PasswordType::Digest,
199 nonce: Some("nonce==".into()),
200 created: Some("2026-04-01T00:00:00Z".into()),
201 });
202 let xml = h.to_xml();
203 assert!(xml.contains("PasswordDigest"));
204 assert!(xml.contains("<wsse:Nonce>nonce=="));
205 assert!(xml.contains("<wsu:Created>2026-04-01T00:00:00Z</wsu:Created>"));
206 }
207
208 #[test]
209 fn timestamp_emits_created_and_expires() {
210 let h = SecurityHeader {
211 timestamp: Some(Timestamp {
212 created: "2026-04-01T00:00:00Z".into(),
213 expires: Some("2026-04-01T00:05:00Z".into()),
214 }),
215 ..SecurityHeader::default()
216 };
217 let xml = h.to_xml();
218 assert!(xml.contains("<wsu:Created>2026-04-01T00:00:00Z</wsu:Created>"));
219 assert!(xml.contains("<wsu:Expires>2026-04-01T00:05:00Z</wsu:Expires>"));
220 }
221
222 #[test]
223 fn x509_token_default_uses_v3_value_type() {
224 let mut h = SecurityHeader::default();
225 h.x509.push(X509Token {
226 cert_b64: "MIIB...".into(),
227 ..X509Token::default()
228 });
229 let xml = h.to_xml();
230 assert!(xml.contains("BinarySecurityToken"));
231 assert!(xml.contains("X509v3"));
232 assert!(xml.contains("MIIB..."));
233 }
234
235 #[test]
236 fn must_understand_flag_emitted() {
237 let h = SecurityHeader {
238 must_understand: true,
239 ..SecurityHeader::default()
240 };
241 let xml = h.to_xml();
242 assert!(xml.contains("soap:mustUnderstand=\"1\""));
243 }
244
245 #[test]
246 fn password_xml_escaped() {
247 let mut h = SecurityHeader::default();
248 h.usernames.push(UsernameToken {
249 username: "u".into(),
250 password: "<bad>&".into(),
251 password_type: PasswordType::Text,
252 ..UsernameToken::default()
253 });
254 let xml = h.to_xml();
255 assert!(xml.contains("<bad>&"));
256 assert!(!xml.contains("<bad>"));
257 }
258
259 #[test]
260 fn password_type_uris_match_spec() {
261 assert!(PasswordType::Text.type_uri().contains("PasswordText"));
262 assert!(PasswordType::Digest.type_uri().contains("PasswordDigest"));
263 }
264}