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";
#[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,
pub(crate) xff_trusted_proxies: usize,
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,
}
}
}
pub struct PasAuthConfig {
pub(super) client: AuthClient,
pub(super) settings: AuthSettings,
}
impl PasAuthConfig {
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(),
})
}
#[must_use]
pub fn from_client(client: AuthClient) -> Self {
Self {
client,
settings: AuthSettings::defaults(),
}
}
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"),);
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>")
)));
}
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
}
#[must_use]
pub fn with_refresh_token_cipher(mut self, cipher: TokenCipher) -> Self {
self.settings.refresh_token_cipher = Some(cipher);
self
}
#[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),
}
}
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");
}
}
}