1use crate::{
2 AUTHORIZATION_HEADER, AuthorizationCarrier, AuthorizationCarrierError, Command,
3 CredentialScheme, DEFAULT_HTTP_ENDPOINT_BASE, ErrorCode, InspectDocument,
4 ProtectedResourceAuthorization,
5};
6
7fn is_credential_token(value: &str) -> bool {
11 !value.is_empty() && value.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
12}
13
14fn is_endpoint_path(value: &str) -> bool {
20 value.starts_with('/')
21 && !value.starts_with("//")
22 && !value.contains('?')
23 && !value.contains('#')
24 && !value.contains('\\')
25 && !value.chars().any(char::is_control)
26 && !value.split('/').any(|segment| {
27 matches!(
28 percent_encoding::percent_decode_str(segment)
29 .decode_utf8()
30 .as_deref(),
31 Ok("." | "..")
32 )
33 })
34}
35
36pub fn normalize_endpoint_base(endpoint_base: Option<&str>) -> Result<String, crate::CoreError> {
37 let endpoint_base = endpoint_base.unwrap_or(DEFAULT_HTTP_ENDPOINT_BASE);
38 if !is_endpoint_path(endpoint_base) {
39 return Err(crate::CoreError::Invalid(
40 "AEP endpoint_base must be an origin-relative absolute path".to_owned(),
41 ));
42 }
43 if endpoint_base.ends_with('/') {
44 Ok(endpoint_base.to_owned())
45 } else {
46 Ok(format!("{endpoint_base}/"))
47 }
48}
49
50pub fn command_path(
51 command: &Command,
52 endpoint_base: Option<&str>,
53) -> Result<String, crate::CoreError> {
54 let relative = match command {
55 Command::Enroll => "enroll",
56 Command::Grant => "grant",
57 Command::Revoke => "revoke",
58 Command::Status => "status",
59 Command::Inspect | Command::Other(_) => {
60 return Err(crate::CoreError::Invalid(
61 "AEP command has no HTTP endpoint path".to_owned(),
62 ));
63 }
64 };
65 Ok(format!(
66 "{}{relative}",
67 normalize_endpoint_base(endpoint_base)?
68 ))
69}
70
71pub fn command_path_from_inspect(
72 document: &InspectDocument,
73 command: &Command,
74) -> Result<String, crate::CoreError> {
75 command_path(command, document.http.endpoint_base.as_deref())
76}
77
78pub const fn protected_resource_authorization_header(
79 carrier: AuthorizationCarrier,
80) -> &'static str {
81 match carrier {
82 AuthorizationCarrier::Standard => "Authorization",
83 AuthorizationCarrier::Dedicated => AUTHORIZATION_HEADER,
84 }
85}
86
87pub fn render_protected_resource_authorization(
88 value: &ProtectedResourceAuthorization,
89) -> Result<(String, String), AuthorizationCarrierError> {
90 validate_protected_resource_authorization(value)?;
91 Ok((
92 protected_resource_authorization_header(value.carrier).to_owned(),
93 format!("{} {}", value.scheme.as_str(), value.credentials),
94 ))
95}
96
97pub fn validate_protected_resource_authorization(
98 value: &ProtectedResourceAuthorization,
99) -> Result<(), AuthorizationCarrierError> {
100 if value.credentials.is_empty() {
101 return Err(AuthorizationCarrierError {
102 code: ErrorCode::InvalidRequest,
103 message: "authorization credentials must not be empty".to_owned(),
104 });
105 }
106 if !is_credential_token(&value.credentials) {
107 return Err(AuthorizationCarrierError {
108 code: ErrorCode::InvalidRequest,
109 message: "authorization credentials must not contain whitespace or control characters"
110 .to_owned(),
111 });
112 }
113 Ok(())
114}
115
116pub fn parse_protected_resource_authorization(
117 field_value: &str,
118 carrier: AuthorizationCarrier,
119) -> Result<ProtectedResourceAuthorization, AuthorizationCarrierError> {
120 if carrier == AuthorizationCarrier::Dedicated && field_value.contains(',') {
121 return Err(not_recognized(
122 "the dedicated authorization field is ambiguous",
123 ));
124 }
125 let Some(separator) = field_value.find([' ', '\t']) else {
130 return Err(not_recognized(
131 "the authorization presentation was not recognized",
132 ));
133 };
134 let (scheme, remainder) = field_value.split_at(separator);
135 let credentials = remainder.trim_matches([' ', '\t']);
136 if scheme.is_empty() || !is_credential_token(credentials) {
137 return Err(not_recognized(
138 "the authorization presentation was not recognized",
139 ));
140 }
141 let scheme = if scheme.eq_ignore_ascii_case("aep") {
142 CredentialScheme::Aep
143 } else if scheme.eq_ignore_ascii_case("bearer") {
144 CredentialScheme::Bearer
145 } else if scheme.eq_ignore_ascii_case("basic") {
146 CredentialScheme::Basic
147 } else {
148 return Err(not_recognized(
149 "the authorization presentation was not recognized",
150 ));
151 };
152 Ok(ProtectedResourceAuthorization {
153 carrier,
154 scheme,
155 credentials: credentials.to_owned(),
156 })
157}
158
159fn not_recognized(message: &str) -> AuthorizationCarrierError {
160 AuthorizationCarrierError {
161 code: ErrorCode::NotRecognized,
162 message: message.to_owned(),
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use proptest::prelude::*;
169
170 use super::*;
171
172 #[test]
173 fn normalizes_command_paths() {
174 assert_eq!(
175 command_path(&Command::Enroll, Some("/custom")).expect("valid path"),
176 "/custom/enroll"
177 );
178 }
179
180 #[test]
181 fn rejects_ambiguous_dedicated_authorization() {
182 let error = parse_protected_resource_authorization(
183 "AEP first, AEP second",
184 AuthorizationCarrier::Dedicated,
185 )
186 .expect_err("combined dedicated field must fail");
187 assert_eq!(error.code, ErrorCode::NotRecognized);
188 }
189
190 #[test]
191 fn parses_and_renders_supported_authorization_schemes() {
192 for (field, rendered_field, scheme) in [
193 ("AEP assertion", "AEP assertion", CredentialScheme::Aep),
194 ("bearer token", "Bearer token", CredentialScheme::Bearer),
195 ("Basic value", "Basic value", CredentialScheme::Basic),
196 ] {
197 let parsed =
198 parse_protected_resource_authorization(field, AuthorizationCarrier::Standard)
199 .expect("recognized authorization");
200 assert_eq!(parsed.scheme, scheme);
201 let (header, rendered) =
202 render_protected_resource_authorization(&parsed).expect("rendered authorization");
203 assert_eq!(header, "Authorization");
204 assert_eq!(rendered, rendered_field);
205 }
206 assert!(
207 parse_protected_resource_authorization("Digest value", AuthorizationCarrier::Standard)
208 .is_err()
209 );
210 assert!(
211 validate_protected_resource_authorization(&ProtectedResourceAuthorization {
212 carrier: AuthorizationCarrier::Dedicated,
213 scheme: CredentialScheme::Aep,
214 credentials: String::new(),
215 })
216 .is_err()
217 );
218 }
219
220 #[test]
221 fn rejects_commands_without_http_paths() {
222 assert!(command_path(&Command::Inspect, None).is_err());
223 assert!(normalize_endpoint_base(Some("https://service.example/aep")).is_err());
224 }
225
226 proptest! {
227 #[test]
228 fn normalized_paths_always_end_in_a_single_separator(
229 segments in proptest::collection::vec("[a-z]{1,8}", 1..5)
230 ) {
231 let input = format!("/{}", segments.join("/"));
232 let normalized = normalize_endpoint_base(Some(&input)).expect("valid path");
233 prop_assert!(normalized.starts_with('/'));
234 prop_assert!(!normalized.starts_with("//"));
235 prop_assert!(normalized.ends_with('/'));
236 }
237 }
238}