Skip to main content

aep_agent/
authentication.rs

1use aep_core::{
2    AssertionOperation, AuthenticationMethod, AuthorizationCarrier, BuiltInGrantResponse,
3    CredentialScheme, parse_built_in_grant_response, render_protected_resource_authorization,
4};
5use base64::{Engine as _, engine::general_purpose::STANDARD};
6use http::{HeaderMap, HeaderName, HeaderValue};
7
8use crate::{
9    AgentError, AuthenticationOptions, AuthenticationResult, CredentialRecord, Inspection, Session,
10    authorization, same_origin,
11};
12
13impl Session {
14    pub async fn authentication(
15        &self,
16        options: AuthenticationOptions,
17    ) -> Result<AuthenticationResult, AgentError> {
18        if options.client_assertion_only
19            && (options.credential_id.is_some() || options.grant_type.is_some())
20        {
21            return Err(AgentError::InvalidConfiguration("AEP credential selection cannot be combined with client-assertion-only authentication".to_owned()));
22        }
23        validate_resource(
24            &options.resource,
25            &self.service_url,
26            self.client.allow_insecure_loopback,
27        )?;
28        let inspection = self.inspect().await?;
29        let methods = inspection
30            .document
31            .authentication
32            .as_ref()
33            .map_or(&[][..], |authentication| authentication.methods.as_slice());
34        if let Some(grant_type) = options.grant_type.as_ref()
35            && !methods
36                .iter()
37                .any(|method| method.as_str() == grant_type.as_str())
38        {
39            return Err(AgentError::NoAuthenticationMethod);
40        }
41        if !options.client_assertion_only {
42            let credential_methods = implicit_credential_methods(methods, &options);
43            if !credential_methods.is_empty()
44                || options.credential_id.is_some()
45                || options.grant_type.is_some()
46            {
47                if let Some(record) = self
48                    .find_credential(&inspection, credential_methods, &options)
49                    .await?
50                {
51                    return credential_authentication(
52                        &record,
53                        options.carrier,
54                        self.client.clock.now(),
55                    );
56                }
57                if options.credential_id.is_some() || options.grant_type.is_some() {
58                    return Err(AgentError::Credential(
59                        "requested AEP credential was not found".to_owned(),
60                    ));
61                }
62            }
63        }
64        if !methods.contains(&AuthenticationMethod::AepJwt) {
65            return Err(AgentError::NoAuthenticationMethod);
66        }
67        let identity = self.resolve_identity(&inspection, true).await?;
68        let signer = self.client.identity_provider.signer_for(&identity).await?;
69        let assertion = self
70            .client
71            .sign_assertion(
72                &inspection,
73                &identity,
74                signer.as_ref(),
75                AssertionOperation::Authenticate,
76                Some(&options.resource),
77            )
78            .await?;
79        let (name, value) = render_protected_resource_authorization(&authorization(
80            options.carrier,
81            CredentialScheme::Aep,
82            assertion,
83        ))
84        .map_err(|error| AgentError::Credential(error.to_string()))?;
85        Ok(AuthenticationResult {
86            headers: one_header(&name, &value)?,
87            method: AuthenticationMethod::AepJwt,
88        })
89    }
90
91    pub async fn forget_credential(&self, credential_id: &str) -> Result<(), AgentError> {
92        if credential_id.is_empty() {
93            return Err(AgentError::Credential(
94                "AEP credential ID is required".to_owned(),
95            ));
96        }
97        let inspection = self.inspect().await?;
98        self.client
99            .credential_store
100            .delete(&inspection.document.service.did, credential_id)
101            .await
102    }
103
104    async fn find_credential(
105        &self,
106        inspection: &Inspection,
107        methods: &[AuthenticationMethod],
108        options: &AuthenticationOptions,
109    ) -> Result<Option<CredentialRecord>, AgentError> {
110        let service_did = &inspection.document.service.did;
111        if let Some(credential_id) = options.credential_id.as_deref() {
112            let Some(record) = self
113                .client
114                .credential_store
115                .find(service_did, credential_id)
116                .await?
117            else {
118                return Ok(None);
119            };
120            validate_record(&record, service_did, self.client.clock.now())?;
121            if options
122                .grant_type
123                .as_ref()
124                .is_some_and(|grant_type| grant_type != &record.grant_type)
125            {
126                return Err(AgentError::Credential(
127                    "stored AEP credential does not match the requested grant type".to_owned(),
128                ));
129            }
130            if !methods
131                .iter()
132                .any(|method| method.as_str() == record.grant_type.as_str())
133            {
134                return Err(AgentError::NoAuthenticationMethod);
135            }
136            return Ok(Some(record));
137        }
138        let records = self.client.credential_store.list(service_did).await?;
139        for method in methods {
140            if let Some(record) = records.iter().find(|record| {
141                method.as_str() == record.grant_type.as_str()
142                    && options
143                        .grant_type
144                        .as_ref()
145                        .is_none_or(|grant_type| grant_type == &record.grant_type)
146            }) {
147                validate_record(record, service_did, self.client.clock.now())?;
148                return Ok(Some(record.clone()));
149            }
150        }
151        Ok(None)
152    }
153}
154
155fn implicit_credential_methods<'a>(
156    methods: &'a [AuthenticationMethod],
157    options: &AuthenticationOptions,
158) -> &'a [AuthenticationMethod] {
159    if options.credential_id.is_some() || options.grant_type.is_some() {
160        return methods;
161    }
162    methods
163        .iter()
164        .position(|method| method == &AuthenticationMethod::AepJwt)
165        .map_or(methods, |index| &methods[..index])
166}
167
168fn credential_authentication(
169    record: &CredentialRecord,
170    carrier: AuthorizationCarrier,
171    now: time::OffsetDateTime,
172) -> Result<AuthenticationResult, AgentError> {
173    validate_record(record, &record.service_did, now)?;
174    let encoded = serde_json::to_vec(&record.payload)?;
175    let credential = parse_built_in_grant_response(&record.grant_type, &encoded)?;
176    match credential {
177        BuiltInGrantResponse::OAuthBearer(value) => {
178            let (name, value) = render_protected_resource_authorization(&authorization(
179                carrier,
180                CredentialScheme::Bearer,
181                value.access_token,
182            ))
183            .map_err(|error| AgentError::Credential(error.to_string()))?;
184            Ok(AuthenticationResult {
185                headers: one_header(&name, &value)?,
186                method: AuthenticationMethod::OAuthBearer,
187            })
188        }
189        BuiltInGrantResponse::ApiKey(value) => Ok(AuthenticationResult {
190            headers: one_header(&value.header, &value.api_key)?,
191            method: AuthenticationMethod::ApiKey,
192        }),
193        BuiltInGrantResponse::Basic(value) => {
194            let credentials = STANDARD.encode(format!("{}:{}", value.username, value.password));
195            let (name, value) = render_protected_resource_authorization(&authorization(
196                carrier,
197                CredentialScheme::Basic,
198                credentials,
199            ))
200            .map_err(|error| AgentError::Credential(error.to_string()))?;
201            Ok(AuthenticationResult {
202                headers: one_header(&name, &value)?,
203                method: AuthenticationMethod::Basic,
204            })
205        }
206    }
207}
208
209pub(crate) fn validate_record(
210    record: &CredentialRecord,
211    service_did: &str,
212    now: time::OffsetDateTime,
213) -> Result<(), AgentError> {
214    if record.credential_id.is_empty()
215        || record.service_did != service_did
216        || record.expires_at <= now
217    {
218        return Err(AgentError::Credential(
219            "stored AEP credential metadata is invalid".to_owned(),
220        ));
221    }
222    let encoded = serde_json::to_vec(&record.payload)?;
223    let credential = parse_built_in_grant_response(&record.grant_type, &encoded)?;
224    let (credential_id, expires_at) = match credential {
225        BuiltInGrantResponse::OAuthBearer(value) => (value.credential_id, value.expires_at),
226        BuiltInGrantResponse::ApiKey(value) => (value.credential_id, value.expires_at),
227        BuiltInGrantResponse::Basic(value) => (value.credential_id, value.expires_at),
228    };
229    let expires_at =
230        time::OffsetDateTime::parse(&expires_at, &time::format_description::well_known::Rfc3339)
231            .map_err(|_| {
232                AgentError::Credential("stored AEP credential expiration is invalid".to_owned())
233            })?;
234    if credential_id != record.credential_id || expires_at != record.expires_at {
235        return Err(AgentError::Credential(
236            "stored AEP credential metadata does not match its payload".to_owned(),
237        ));
238    }
239    Ok(())
240}
241
242fn validate_resource(
243    resource: &url::Url,
244    service: &url::Url,
245    allow_insecure_loopback: bool,
246) -> Result<(), AgentError> {
247    if !resource.username().is_empty()
248        || resource.password().is_some()
249        || resource.fragment().is_some()
250        || resource.host_str().is_none()
251    {
252        return Err(AgentError::InvalidServiceReference(
253            "AEP protected resource URL is invalid".to_owned(),
254        ));
255    }
256    if resource.scheme() != "https"
257        && !(allow_insecure_loopback && resource.scheme() == "http" && crate::is_loopback(resource))
258    {
259        return Err(AgentError::InvalidServiceReference(
260            "AEP protected resource requires HTTPS".to_owned(),
261        ));
262    }
263    if !same_origin(resource, service) {
264        return Err(AgentError::InvalidServiceReference(
265            "AEP protected resource must use the Service origin".to_owned(),
266        ));
267    }
268    Ok(())
269}
270
271fn one_header(name: &str, value: &str) -> Result<HeaderMap, AgentError> {
272    let name = HeaderName::from_bytes(name.as_bytes())
273        .map_err(|_| AgentError::Credential("AEP credential header name is invalid".to_owned()))?;
274    let value = HeaderValue::from_str(value)
275        .map_err(|_| AgentError::Credential("AEP credential header value is invalid".to_owned()))?;
276    let mut headers = HeaderMap::new();
277    headers.insert(name, value);
278    Ok(headers)
279}