Skip to main content

aep_core/
http.rs

1use crate::{
2    AUTHORIZATION_HEADER, AuthorizationCarrier, AuthorizationCarrierError, Command,
3    CredentialScheme, DEFAULT_HTTP_ENDPOINT_BASE, ErrorCode, InspectDocument,
4    ProtectedResourceAuthorization,
5};
6
7/// Visible US-ASCII excluding space, which is what the `AEP`, `Bearer`, and `Basic` credential
8/// syntaxes admit. Excluding everything else keeps CR, LF, NUL, and other control characters out
9/// of a rendered field value, and keeps one field value from carrying two credentials.
10fn is_credential_token(value: &str) -> bool {
11    !value.is_empty() && value.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
12}
13
14/// Reports whether a value can carry a command path appended to it.
15///
16/// [`command_path`] appends the relative path with exactly one `/` separator, so a base carrying a
17/// query, a fragment, or a dot segment cannot satisfy that: the command name would land in the
18/// query string or walk out of the advertised base.
19fn 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    // CORE-AUTH-001 spells the presentation as `"AEP" 1*SP compact-jws`, so repeated separators
126    // belong to the grammar and a field value may carry trailing optional whitespace. The
127    // credential itself is a visible US-ASCII token, which is what keeps one field value from
128    // encoding more than one credential (CORE-PR-018).
129    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}