aep-service 0.2.0

Service-side integration for the Agent Enrollment Protocol.
Documentation
use std::{collections::BTreeMap, sync::Arc};

use aep_core::{
    AgentStatus, ApiKeyGrantResponse, BasicGrantResponse, BuiltInGrantResponse, ClaimValues,
    GrantRequest, GrantType, GrantTypeConfig, OAuthBearerGrantResponse, RevokeRequest,
    StringBoolean,
};
use aep_service::{
    AuthenticationKind, CredentialAuthenticationInput, EnrollmentRecord, GrantContext,
    MemoryServiceCredentialStore, ServiceCredentialRecord, ServiceCredentialStore,
    StoredApiKeyGrantTypeOptions, StoredBasicGrantTypeOptions, StoredOAuthBearerGrantTypeOptions,
    stored_api_key_grant_type, stored_basic_grant_type, stored_oauth_bearer_grant_type,
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use futures::{FutureExt as _, executor::block_on};
use http::{HeaderMap, HeaderValue, header};
use serde_json::json;
use time::{Duration, OffsetDateTime};

#[test]
fn issues_authenticates_and_revokes_built_in_credentials() {
    block_on(async {
        let store = Arc::new(MemoryServiceCredentialStore::default());
        let oauth = stored_oauth_bearer_grant_type(StoredOAuthBearerGrantTypeOptions {
            config: GrantTypeConfig::default(),
            issue: Arc::new(|_, context| {
                async move {
                    Ok(OAuthBearerGrantResponse {
                        access_token: "oauth-secret".to_owned(),
                        additional: BTreeMap::new(),
                        credential_id: "oauth-1".to_owned(),
                        expires_at: expiration(context.now),
                        scopes: vec!["read".to_owned()],
                        token_type: "Bearer".to_owned(),
                    })
                }
                .boxed()
            }),
            store: store.clone(),
        })
        .expect("OAuth Bearer definition");
        let api_key = stored_api_key_grant_type(StoredApiKeyGrantTypeOptions {
            config: GrantTypeConfig {
                additional: BTreeMap::from([("header_names".to_owned(), json!(["x-api-key"]))]),
                supports_per_credential_revoke: None,
            },
            issue: Arc::new(|_, context| {
                async move {
                    Ok(ApiKeyGrantResponse {
                        additional: BTreeMap::new(),
                        api_key: "api-secret".to_owned(),
                        credential_id: "api-1".to_owned(),
                        expires_at: expiration(context.now),
                        header: "X-API-Key".to_owned(),
                        scopes: vec!["purchase".to_owned()],
                    })
                }
                .boxed()
            }),
            store: store.clone(),
        })
        .expect("API-key definition");
        let basic = stored_basic_grant_type(StoredBasicGrantTypeOptions {
            config: GrantTypeConfig::default(),
            issue: Arc::new(|_, context| {
                async move {
                    Ok(BasicGrantResponse {
                        additional: BTreeMap::new(),
                        credential_id: "basic-1".to_owned(),
                        expires_at: expiration(context.now),
                        password: "basic-secret".to_owned(),
                        realm: Some("service".to_owned()),
                        scopes: vec!["read".to_owned()],
                        username: "agent".to_owned(),
                    })
                }
                .boxed()
            }),
            store: store.clone(),
        })
        .expect("Basic definition");

        assert_eq!(
            api_key
                .config
                .as_ref()
                .and_then(|config| config.supports_per_credential_revoke),
            Some(StringBoolean::True)
        );
        for definition in [&oauth, &api_key, &basic] {
            let request = GrantRequest {
                additional: BTreeMap::new(),
                grant_type: definition.grant_type.clone(),
                requested_scopes: Vec::new(),
            };
            definition
                .handler
                .as_ref()
                .expect("handler")
                .grant(&request, &context(definition.grant_type.clone()))
                .await
                .expect("issued credential");
        }

        let cases = [
            (
                &oauth,
                headers("AEP-Authorization", "Bearer oauth-secret"),
                "oauth-1",
            ),
            (&api_key, headers("X-API-Key", "api-secret"), "api-1"),
            (
                &basic,
                headers(
                    header::AUTHORIZATION.as_str(),
                    &format!("Basic {}", STANDARD.encode("agent:basic-secret")),
                ),
                "basic-1",
            ),
        ];
        for (definition, headers, credential_id) in cases {
            let input = CredentialAuthenticationInput {
                headers,
                now: now(),
            };
            let principal = definition
                .handler
                .as_ref()
                .expect("handler")
                .authenticate(&input)
                .await
                .expect("authentication")
                .expect("principal");
            assert_eq!(
                principal.authentication_kind,
                AuthenticationKind::SessionCredential
            );
            assert_eq!(principal.credential_id.as_deref(), Some(credential_id));
        }

        oauth
            .handler
            .as_ref()
            .expect("handler")
            .revoke(
                &RevokeRequest {
                    additional: BTreeMap::new(),
                    all_grant_types: None,
                    credential_id: Some("oauth-1".to_owned()),
                    grant_type: Some(GrantType::OAuthBearer),
                },
                &context(GrantType::OAuthBearer),
            )
            .await
            .expect("revoke");
        assert!(
            oauth
                .handler
                .as_ref()
                .expect("handler")
                .authenticate(&CredentialAuthenticationInput {
                    headers: headers(header::AUTHORIZATION.as_str(), "Bearer oauth-secret"),
                    now: now(),
                })
                .await
                .expect("authentication")
                .is_none()
        );

        basic
            .handler
            .as_ref()
            .expect("handler")
            .revoke(
                &RevokeRequest {
                    additional: BTreeMap::new(),
                    all_grant_types: None,
                    credential_id: None,
                    grant_type: Some(GrantType::Basic),
                },
                &context(GrantType::Basic),
            )
            .await
            .expect("grant-type revoke");
        assert!(
            basic
                .handler
                .as_ref()
                .expect("handler")
                .authenticate(&CredentialAuthenticationInput {
                    headers: headers(
                        header::AUTHORIZATION.as_str(),
                        &format!("Basic {}", STANDARD.encode("agent:basic-secret")),
                    ),
                    now: now(),
                })
                .await
                .expect("authentication")
                .is_none()
        );
        assert!(
            api_key
                .handler
                .as_ref()
                .expect("handler")
                .authenticate(&CredentialAuthenticationInput {
                    headers: headers("X-API-Key", "api-secret"),
                    now: now() + Duration::hours(2),
                })
                .await
                .expect("authentication")
                .is_none()
        );
    });
}

#[test]
fn rejects_invalid_api_key_configuration_and_issued_header() {
    block_on(async {
        let store = Arc::new(MemoryServiceCredentialStore::default());
        let duplicate = GrantTypeConfig {
            additional: BTreeMap::from([(
                "header_names".to_owned(),
                json!(["X-API-Key", "x-api-key"]),
            )]),
            supports_per_credential_revoke: None,
        };
        let issue = Arc::new(|_: GrantRequest, context: GrantContext| {
            async move {
                Ok(ApiKeyGrantResponse {
                    additional: BTreeMap::new(),
                    api_key: "api-secret".to_owned(),
                    credential_id: "api-1".to_owned(),
                    expires_at: expiration(context.now),
                    header: "other-key".to_owned(),
                    scopes: Vec::new(),
                })
            }
            .boxed()
        });
        assert!(
            stored_api_key_grant_type(StoredApiKeyGrantTypeOptions {
                config: duplicate,
                issue: issue.clone(),
                store: store.clone(),
            })
            .is_err()
        );
        let definition = stored_api_key_grant_type(StoredApiKeyGrantTypeOptions {
            config: GrantTypeConfig {
                additional: BTreeMap::from([("header_names".to_owned(), json!(["x-api-key"]))]),
                supports_per_credential_revoke: None,
            },
            issue,
            store,
        })
        .expect("definition");
        assert!(
            definition
                .handler
                .expect("handler")
                .grant(
                    &GrantRequest {
                        additional: BTreeMap::new(),
                        grant_type: GrantType::ApiKey,
                        requested_scopes: Vec::new(),
                    },
                    &context(GrantType::ApiKey),
                )
                .await
                .is_err()
        );
    });
}

#[test]
fn rejects_reassigned_credential_identifiers_and_wrong_api_key_carriers() {
    block_on(async {
        let store = MemoryServiceCredentialStore::default();
        let record = api_key_record("first-secret");
        store.save(record.clone()).await.expect("saved credential");
        let mut reassigned = record;
        reassigned.credential = BuiltInGrantResponse::ApiKey(ApiKeyGrantResponse {
            api_key: "second-secret".to_owned(),
            ..api_key_response("second-secret")
        });
        assert!(store.save(reassigned).await.is_err());

        let input = CredentialAuthenticationInput {
            headers: headers("Service-API-Key", "first-secret"),
            now: now(),
        };
        assert!(
            !store
                .has_presentation(&GrantType::ApiKey, &input)
                .await
                .expect("presentation")
        );
        assert!(
            store
                .authenticate(&GrantType::ApiKey, &input)
                .await
                .expect("authentication")
                .is_none()
        );

        let invalid = CredentialAuthenticationInput {
            headers: headers("X-API-Key", "invalid-secret"),
            now: now(),
        };
        assert!(
            store
                .has_presentation(&GrantType::ApiKey, &invalid)
                .await
                .expect("presentation")
        );
        assert!(
            store
                .authenticate(&GrantType::ApiKey, &invalid)
                .await
                .expect("authentication")
                .is_none()
        );
    });
}

fn now() -> OffsetDateTime {
    OffsetDateTime::from_unix_timestamp(1_788_177_600).expect("time")
}

fn expiration(created_at: OffsetDateTime) -> String {
    (created_at + Duration::hours(1))
        .format(&time::format_description::well_known::Rfc3339)
        .expect("expiration")
}

fn context(grant_type: GrantType) -> GrantContext {
    GrantContext {
        agent_did: "did:web:agent.example".to_owned(),
        enrollment: EnrollmentRecord {
            agent_did: "did:web:agent.example".to_owned(),
            claims: ClaimValues::default(),
            created_at: now(),
            owner_action_required: false,
            requirements_pending: Vec::new(),
            since: now(),
            status: AgentStatus::Active,
            updated_at: now(),
            verification_pending: Vec::new(),
        },
        grant_type,
        now: now(),
    }
}

fn headers(name: &str, value: &str) -> HeaderMap {
    let mut headers = HeaderMap::new();
    headers.insert(
        http::HeaderName::try_from(name).expect("header name"),
        HeaderValue::try_from(value).expect("header value"),
    );
    headers
}

fn api_key_response(secret: &str) -> ApiKeyGrantResponse {
    ApiKeyGrantResponse {
        additional: BTreeMap::new(),
        api_key: secret.to_owned(),
        credential_id: "credential-1".to_owned(),
        expires_at: expiration(now()),
        header: "x-api-key".to_owned(),
        scopes: Vec::new(),
    }
}

fn api_key_record(secret: &str) -> ServiceCredentialRecord {
    ServiceCredentialRecord {
        agent_did: "did:web:agent.example".to_owned(),
        created_at: now(),
        credential: BuiltInGrantResponse::ApiKey(api_key_response(secret)),
        credential_id: "credential-1".to_owned(),
        expires_at: now() + Duration::hours(1),
        grant_type: GrantType::ApiKey,
    }
}