pas-external 4.0.2

Ppoppo Accounts System (PAS) external SDK -- OAuth2 PKCE, PASETO verification, Axum middleware, session liveness
Documentation
use axum_extra::extract::cookie::Key;
use url::Url;

use super::error::AuthError;
use crate::oauth::{AuthClient, OAuthConfig};
use crate::session_liveness::TokenCipher;

const DEFAULT_SESSION_COOKIE_NAME: &str = "__ppoppo_session";
const DEFAULT_SESSION_TTL_DAYS: i64 = 30;
const DEFAULT_AUTH_PATH: &str = "/api/auth";
const DEFAULT_ERROR_REDIRECT: &str = "/login";

/// Shared auth settings used by both config and runtime state.
#[derive(Clone)]
pub(crate) struct AuthSettings {
    pub(crate) cookie_key: Key,
    pub(crate) session_cookie_name: String,
    pub(crate) session_ttl_days: i64,
    pub(crate) secure_cookies: bool,
    pub(crate) auth_path: String,
    pub(crate) login_redirect: String,
    pub(crate) logout_redirect: String,
    pub(crate) error_redirect: String,
    pub(crate) dev_login_enabled: bool,
    /// Number of trusted reverse-proxy hops between the SDK and the public
    /// internet. The session callback walks `X-Forwarded-For` from the right
    /// and **skips this many entries** before reading the client IP. Default
    /// `0` matches single-hop deployments (one trusted edge proxy that
    /// appended the client IP itself); GKE Ingress + GFE typically needs
    /// `1` or `2`. Misconfigured = audit logs record proxy IPs, not clients.
    pub(crate) xff_trusted_proxies: usize,
    /// Cipher used to encrypt PAS `refresh_token`s before handing them to
    /// `SessionStore::create`. When `None`, refresh tokens are dropped
    /// (`NewSession.refresh_token = None`) — DEV_AUTH flows or consumers
    /// that explicitly opt out of refresh-token persistence.
    pub(crate) refresh_token_cipher: Option<TokenCipher>,
}

impl AuthSettings {
    fn defaults() -> Self {
        Self {
            cookie_key: Key::generate(),
            session_cookie_name: DEFAULT_SESSION_COOKIE_NAME.into(),
            session_ttl_days: DEFAULT_SESSION_TTL_DAYS,
            secure_cookies: true,
            auth_path: DEFAULT_AUTH_PATH.into(),
            login_redirect: "/".into(),
            logout_redirect: "/".into(),
            error_redirect: DEFAULT_ERROR_REDIRECT.into(),
            dev_login_enabled: false,
            xff_trusted_proxies: 0,
            refresh_token_cipher: None,
        }
    }
}

/// PAS authentication configuration.
///
/// Required field (`client`) is a constructor parameter — no runtime "missing field" errors.
///
/// Use [`from_env()`](PasAuthConfig::from_env) for convention-based setup,
/// or [`try_new()`](PasAuthConfig::try_new) with `with_*` methods for full control.
pub struct PasAuthConfig {
    pub(super) client: AuthClient,
    pub(super) settings: AuthSettings,
}

impl PasAuthConfig {
    /// Create a new PAS auth config with required fields.
    ///
    /// Optional fields (auth_url, token_url, userinfo_url, scopes) default to
    /// production PAS endpoints. Override with `with_*` methods.
    ///
    /// # Errors
    ///
    /// Returns [`AuthError::Config`] if the underlying HTTP client cannot be
    /// built (TLS init failure, OS resource exhaustion).
    pub fn try_new(client_id: impl Into<String>, redirect_uri: Url) -> Result<Self, AuthError> {
        let config = OAuthConfig::new(client_id, redirect_uri);
        Ok(Self {
            client: AuthClient::try_new(config).map_err(|e| AuthError::Config(e.to_string()))?,
            settings: AuthSettings::defaults(),
        })
    }

    /// Create config from a pre-built [`AuthClient`].
    ///
    /// All optional fields use sensible defaults. Override with `with_*` methods.
    #[must_use]
    pub fn from_client(client: AuthClient) -> Self {
        Self {
            client,
            settings: AuthSettings::defaults(),
        }
    }

    /// Create config from environment variables.
    ///
    /// # Required env vars
    /// - `PAS_CLIENT_ID`: OAuth2 client ID
    /// - `PAS_REDIRECT_URI`: OAuth2 callback URI (must be a valid URL)
    ///
    /// # Optional env vars
    /// - `PAS_AUTH_URL`: Override PAS authorize endpoint
    /// - `PAS_TOKEN_URL`: Override PAS token endpoint
    /// - `PAS_USERINFO_URL`: Override PAS userinfo endpoint
    /// - `PAS_SCOPES`: Comma-separated OAuth2 scopes
    /// - `DEV_AUTH`: Set to `"1"` or `"true"` to enable dev-login route and disable secure cookies
    /// - `COOKIE_KEY`: Cookie encryption key bytes
    ///
    /// # Errors
    ///
    /// Returns [`AuthError::Config`] if required env vars are missing or URLs are invalid.
    pub fn from_env() -> Result<Self, AuthError> {
        let client_id = std::env::var("PAS_CLIENT_ID")
            .map_err(|_| AuthError::Config("PAS_CLIENT_ID is required".into()))?;
        let redirect_uri_str = std::env::var("PAS_REDIRECT_URI")
            .map_err(|_| AuthError::Config("PAS_REDIRECT_URI is required".into()))?;
        let redirect_uri: Url = redirect_uri_str
            .parse()
            .map_err(|e| AuthError::Config(format!("PAS_REDIRECT_URI: {e}")))?;

        let mut config = OAuthConfig::new(client_id, redirect_uri);

        if let Some(url) = parse_optional_url_env("PAS_AUTH_URL")? {
            config = config.with_auth_url(url);
        }
        if let Some(url) = parse_optional_url_env("PAS_TOKEN_URL")? {
            config = config.with_token_url(url);
        }
        if let Some(url) = parse_optional_url_env("PAS_USERINFO_URL")? {
            config = config.with_userinfo_url(url);
        }
        if let Ok(scopes) = std::env::var("PAS_SCOPES") {
            config = config.with_scopes(scopes.split(',').map(|s| s.trim().to_string()).collect());
        }

        let dev_auth = matches!(std::env::var("DEV_AUTH").as_deref(), Ok("1") | Ok("true"),);

        // C1 — dev_login impersonates *any* ppnum. The only meaningful guard is
        // "this process cannot possibly be answering production traffic," which we
        // approximate as `redirect_uri` host being a loopback address. A typo'd
        // helm value or leaked overlay that flips DEV_AUTH on while pointing at a
        // public redirect_uri now refuses to start instead of silently allowing
        // full identity bypass.
        if dev_auth && !redirect_uri_is_loopback(config.redirect_uri()) {
            return Err(AuthError::Config(format!(
                "DEV_AUTH=1 refused: PAS_REDIRECT_URI host '{}' is not a loopback address. \
                 Dev login allows impersonating any ppnum; it MUST NOT run with a public \
                 redirect URI. Either unset DEV_AUTH or point PAS_REDIRECT_URI at \
                 localhost / 127.0.0.1 / [::1].",
                config.redirect_uri().host_str().unwrap_or("<missing>")
            )));
        }

        // C2 — In secure-cookie mode, an ephemeral key silently breaks multi-replica
        // deployments (cookies signed by replica A can't be decrypted by replica B,
        // users bounce between "authenticated" and "session expired"). A startup
        // warning gets buried in 10k log lines. Refuse to start instead.
        let cookie_key = match std::env::var("COOKIE_KEY") {
            Ok(k) => Key::try_from(k.as_bytes()).map_err(|_| {
                AuthError::Config(
                    "COOKIE_KEY is set but invalid (must be at least 64 bytes). \
                     Generate one with `openssl rand -base64 64`."
                        .into(),
                )
            })?,
            Err(_) if dev_auth => {
                tracing::warn!(
                    "COOKIE_KEY not set — using ephemeral key (DEV_AUTH mode). \
                     All sessions will be invalidated on restart."
                );
                Key::generate()
            }
            Err(_) => {
                return Err(AuthError::Config(
                    "COOKIE_KEY is required when DEV_AUTH is not set. \
                     An ephemeral key silently breaks multi-replica deployments. \
                     Generate one with `openssl rand -base64 64` and store as a secret."
                        .into(),
                ));
            }
        };

        if dev_auth {
            tracing::warn!(
                "DEV_AUTH is enabled — dev-login route is active and secure cookies are disabled. \
                 This MUST NOT be used in production. (Loopback-host guard passed.)"
            );
        }

        Ok(Self::from_client(AuthClient::try_new(config)?)
            .with_cookie_key(cookie_key)
            .with_secure_cookies(!dev_auth)
            .with_dev_login_enabled(dev_auth))
    }

    #[must_use]
    pub fn with_cookie_key(mut self, key: Key) -> Self {
        self.settings.cookie_key = key;
        self
    }

    #[must_use]
    pub fn with_session_cookie_name(mut self, name: impl Into<String>) -> Self {
        self.settings.session_cookie_name = name.into();
        self
    }

    #[must_use]
    pub fn with_session_ttl_days(mut self, days: i64) -> Self {
        self.settings.session_ttl_days = days;
        self
    }

    #[must_use]
    pub fn with_secure_cookies(mut self, secure: bool) -> Self {
        self.settings.secure_cookies = secure;
        self
    }

    #[must_use]
    pub fn with_auth_path(mut self, path: impl Into<String>) -> Self {
        self.settings.auth_path = path.into();
        self
    }

    #[must_use]
    pub fn with_login_redirect(mut self, path: impl Into<String>) -> Self {
        self.settings.login_redirect = path.into();
        self
    }

    #[must_use]
    pub fn with_logout_redirect(mut self, path: impl Into<String>) -> Self {
        self.settings.logout_redirect = path.into();
        self
    }

    #[must_use]
    pub fn with_error_redirect(mut self, path: impl Into<String>) -> Self {
        self.settings.error_redirect = path.into();
        self
    }

    #[must_use]
    pub fn with_dev_login_enabled(mut self, enabled: bool) -> Self {
        self.settings.dev_login_enabled = enabled;
        self
    }

    /// Configure the cipher used to encrypt PAS `refresh_token`s before they
    /// reach `SessionStore::create`.
    ///
    /// **Strongly recommended.** Without this, every successful OAuth login
    /// drops the refresh_token (`NewSession.refresh_token = None`) and the
    /// session is one-shot — no liveness checks, no proactive revocation
    /// detection. Per [`STANDARDS_SESSION_LIVENESS.md`](../../../0context/STANDARDS_SESSION_LIVENESS.md)
    /// invariant S-L1, the stored refresh_token must be at-rest encrypted
    /// with AES-256-GCM; the SDK enforces this at the type level by handing
    /// consumer code an [`EncryptedRefreshToken`](crate::session_liveness::EncryptedRefreshToken)
    /// rather than a plaintext `String`.
    ///
    /// Pass the same [`TokenCipher`] you use for liveness checks.
    #[must_use]
    pub fn with_refresh_token_cipher(mut self, cipher: TokenCipher) -> Self {
        self.settings.refresh_token_cipher = Some(cipher);
        self
    }

    /// Number of trusted reverse-proxy hops between the SDK and the public
    /// internet.
    ///
    /// The OAuth callback walks `X-Forwarded-For` from the right and **skips**
    /// `n` entries before reading the client IP. The value is the count of
    /// proxies you trust to have appended honest IPs:
    ///
    /// | Topology | `n` | Reason |
    /// |----------|-----|--------|
    /// | Direct (no proxy) | `0` | XFF would not be present anyway |
    /// | Single edge proxy (nginx, Caddy, ALB) | `1` | The proxy added the client IP at position `len-1` |
    /// | GKE Ingress (HTTPS LB → kube-proxy) | `2` | GFE + LB both append themselves |
    /// | CDN + LB | `3` | CDN + ingress + service hop |
    ///
    /// **Default: `0`.** Only set this if your deployment has trusted edge
    /// proxies between the public internet and this service. Setting it too
    /// low records proxy IPs as client IPs in audit logs; setting it too high
    /// allows clients to spoof their IP via crafted `X-Forwarded-For` headers.
    #[must_use]
    pub fn with_xff_trusted_proxies(mut self, n: usize) -> Self {
        self.settings.xff_trusted_proxies = n;
        self
    }
}

fn parse_optional_url_env(key: &str) -> Result<Option<Url>, AuthError> {
    match std::env::var(key) {
        Ok(s) => {
            let url: Url = s
                .parse()
                .map_err(|e| AuthError::Config(format!("{key}: {e}")))?;
            Ok(Some(url))
        }
        Err(_) => Ok(None),
    }
}

/// True iff the URL's host is a loopback literal (`localhost`, `127.0.0.1`, `::1`).
///
/// We deliberately do not resolve hostnames — DNS-based bypass would defeat the
/// guard. A consumer running on a private network with a non-loopback hostname
/// must construct `PasAuthConfig` programmatically and call
/// [`PasAuthConfig::with_dev_login_enabled`] directly, accepting responsibility.
fn redirect_uri_is_loopback(url: &Url) -> bool {
    matches!(
        url.host_str(),
        Some("localhost") | Some("127.0.0.1") | Some("[::1]") | Some("::1")
    )
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn loopback_host_accepted() {
        for host in ["http://localhost", "http://127.0.0.1", "http://[::1]"] {
            let url: Url = format!("{host}/cb").parse().unwrap();
            assert!(redirect_uri_is_loopback(&url), "{host} should be loopback");
        }
    }

    #[test]
    fn public_host_rejected() {
        for host in [
            "https://accounts.ppoppo.com",
            "https://rollcall.run",
            "https://classytime.me",
            "http://192.168.1.1",
            "http://10.0.0.1",
        ] {
            let url: Url = format!("{host}/cb").parse().unwrap();
            assert!(!redirect_uri_is_loopback(&url), "{host} should NOT be loopback");
        }
    }
}