aegis-api 0.4.2

Independent Aegis fleet control plane HTTP service.
Documentation
use async_trait::async_trait;
use phylax_core::{
    AuthorizationCodeOAuthPolicy, FormRevokeTokenGrant, OAuthAccessGrant, OAuthAccessGrantError,
    OAuthVerifiedIdentity, ScopeSet, Subject,
};
use phylax_gcp::FirestoreAuthStore;
use phylax_gcp::identity::UserRecord;
use serde::Serialize;
use std::net::IpAddr;
use time::OffsetDateTime;
use url::Url;
#[derive(Clone)]
pub struct OAuthAccessPolicy {
    pub identities: phylax_gcp::identity::IdentityStore,
    pub provider: String,
    pub public_client_id: String,
    pub audience: String,
    pub base_scopes: Vec<String>,
}

impl OAuthAccessPolicy {
    fn scopes(&self) -> anyhow::Result<ScopeSet> {
        ScopeSet::new(self.base_scopes.iter().map(String::as_str))
    }
}

#[derive(Clone, Debug, Serialize)]
pub struct OAuthTokenExtra {
    pub principal: String,
}

impl OAuthTokenExtra {
    fn for_user(user: &UserRecord) -> Self {
        Self {
            principal: user.id.clone(),
        }
    }
}

#[async_trait]
impl AuthorizationCodeOAuthPolicy for OAuthAccessPolicy {
    type Extra = OAuthTokenExtra;

    fn validate_redirect_uri(&self, redirect_uri: &Url) -> anyhow::Result<()> {
        validate_local_redirect(redirect_uri)
    }

    async fn access_grant(
        &self,
        identity: &OAuthVerifiedIdentity,
    ) -> Result<OAuthAccessGrant<Self::Extra>, OAuthAccessGrantError> {
        let user = require_authorized_user(
            self.identities
                .authorize(&self.provider, &identity.provider_sub)
                .await?,
        )?;
        if user.disabled {
            return Err(OAuthAccessGrantError::access_denied(anyhow::anyhow!(
                "OAuth principal is disabled"
            )));
        }
        Ok(OAuthAccessGrant {
            subject: oauth_user_subject(&user.id)?,
            client_id: self.public_client_id.clone(),
            audience: vec![self.audience.clone()],
            scope: self.scopes()?,
            extra: OAuthTokenExtra::for_user(&user),
        })
    }
}

fn require_authorized_user(user: Option<UserRecord>) -> Result<UserRecord, OAuthAccessGrantError> {
    user.ok_or_else(|| {
        OAuthAccessGrantError::access_denied(anyhow::anyhow!("OAuth principal is not authorized"))
    })
}

#[derive(Clone)]
pub struct UserRevokeTokenGrant {
    pub auth: FirestoreAuthStore,
}

#[async_trait]
impl FormRevokeTokenGrant for UserRevokeTokenGrant {
    async fn revoke_refresh_token(&self, token: &str) -> anyhow::Result<()> {
        self.auth
            .revoke_oauth_refresh_token_value(token, OffsetDateTime::now_utc().unix_timestamp())
            .await
    }
}

fn validate_local_redirect(url: &Url) -> anyhow::Result<()> {
    if url.scheme() != "http" {
        anyhow::bail!("redirect_uri must use http (loopback)");
    }
    if url.fragment().is_some() {
        anyhow::bail!("redirect_uri must not contain a fragment");
    }
    if !is_loopback_ip_literal(url) {
        anyhow::bail!("redirect_uri must use a loopback IP literal");
    }
    Ok(())
}

fn oauth_user_subject(user_id: &str) -> anyhow::Result<Subject> {
    anyhow::ensure!(
        !user_id.is_empty()
            && user_id.trim() == user_id
            && !user_id.contains(char::is_whitespace)
            && !user_id.contains('@'),
        "user id is invalid"
    );
    Subject::new(format!("user:{user_id}"))
}

fn is_loopback_ip_literal(url: &Url) -> bool {
    url.host_str()
        .map(|host| host.trim_start_matches('[').trim_end_matches(']'))
        .and_then(|host| host.parse::<IpAddr>().ok())
        .is_some_and(|ip| ip.is_loopback())
}

#[cfg(test)]
mod tests {
    use super::{
        OAuthTokenExtra, oauth_user_subject, require_authorized_user, validate_local_redirect,
    };
    use phylax_core::OAuthAccessGrantError;
    use phylax_gcp::identity::UserRecord;
    use url::Url;

    #[test]
    fn loopback_http_redirect_is_allowed() {
        validate_local_redirect(&Url::parse("http://127.0.0.1:7777/callback").unwrap())
            .expect("loopback http should be allowed");
        validate_local_redirect(&Url::parse("http://127.0.0.2:7777/callback").unwrap())
            .expect("loopback http should be allowed");
        validate_local_redirect(&Url::parse("http://[::1]:7777/callback").unwrap())
            .expect("IPv6 loopback http should be allowed");
    }

    #[test]
    fn non_loopback_or_https_redirect_is_rejected() {
        validate_local_redirect(&Url::parse("http://example.com/callback").unwrap())
            .expect_err("non-loopback redirect should fail");
        validate_local_redirect(&Url::parse("http://localhost:7777/callback").unwrap())
            .expect_err("localhost redirect should fail");
        validate_local_redirect(&Url::parse("https://127.0.0.1/callback").unwrap())
            .expect_err("https loopback redirect should fail");
        validate_local_redirect(&Url::parse("http://127.0.0.1/callback#fragment").unwrap())
            .expect_err("fragment redirect should fail");
    }

    #[test]
    fn stable_user_ids_reject_email_addresses() {
        oauth_user_subject("OpaqueUserID").expect("opaque stable id should validate");
        oauth_user_subject("user@example.com")
            .expect_err("email addresses are not stable user IDs");
    }

    #[test]
    fn token_extra_uses_the_stable_user_id() {
        assert_eq!(
            "user-1",
            OAuthTokenExtra::for_user(&UserRecord {
                id: "user-1".into(),
                email: "user@example.com".into(),
                disabled: false,
                session_version: 0,
            })
            .principal
        );
    }

    #[test]
    fn missing_user_record_is_denied() {
        let error = require_authorized_user(None).expect_err("missing user must be denied");
        match error {
            OAuthAccessGrantError::AccessDenied(error) => {
                assert_eq!("OAuth principal is not authorized", error.to_string());
            }
            OAuthAccessGrantError::Internal(error) => {
                panic!("missing user returned an internal error: {error}");
            }
        }
    }

    #[test]
    fn existing_identity_can_authenticate_without_namespace_permissions() {
        assert_eq!(
            require_authorized_user(Some(UserRecord {
                id: "user-1".into(),
                email: "user@example.com".into(),
                disabled: false,
                session_version: 0,
            }))
            .unwrap()
            .id,
            "user-1"
        );
    }
}