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