use std::any::Any;
use std::sync::{Arc, RwLock};
use async_trait::async_trait;
use pubky_common::{auth::AuthToken, crypto::PublicKey, session::CookieSessionRecord};
use reqwest::{Method, RequestBuilder, Response};
use crate::actors::session::core::PubkySession;
use crate::actors::session::credential::{SessionCredential, credential_session_missing};
use crate::{
Error, PubkyHttpClient,
actors::session::SessionInfo,
actors::storage::resource::resolve_pubky,
cross_log,
errors::{PkarrError, Result},
util::check_http_status,
};
#[cfg(not(target_arch = "wasm32"))]
use crate::errors::AuthError;
const SESSION_PATH: &str = "/session";
#[derive(Clone, Debug)]
pub struct CookieCredential {
user: PublicKey,
record: Arc<RwLock<CookieSessionRecord>>,
cookie: Option<String>,
homeserver: Arc<RwLock<Option<PublicKey>>>,
}
impl CookieCredential {
pub(crate) fn new(
user: PublicKey,
cookie: Option<String>,
record: CookieSessionRecord,
homeserver: Option<PublicKey>,
) -> Self {
Self {
user,
record: Arc::new(RwLock::new(record)),
cookie,
homeserver: Arc::new(RwLock::new(homeserver)),
}
}
pub(crate) fn set_homeserver(&self, homeserver: PublicKey) {
if let Ok(mut hs) = self.homeserver.write() {
*hs = Some(homeserver);
}
}
fn bound_homeserver(&self) -> Option<PublicKey> {
self.homeserver.read().ok().and_then(|hs| hs.clone())
}
fn homeserver_for_unbound_session(
user: &PublicKey,
resolution: Result<Option<PublicKey>>,
) -> Result<Option<PublicKey>> {
match resolution {
Ok(homeserver) => Ok(homeserver),
Err(Error::Pkarr(PkarrError::Resolve(error))) => {
cross_log!(
warn,
"Homeserver lookup for {user} failed; falling back to pubky URL routing: {error}"
);
Ok(None)
}
Err(error) => Err(error),
}
}
async fn resolve_unbound_homeserver(
client: &PubkyHttpClient,
user: &PublicKey,
) -> Result<Option<PublicKey>> {
let resolution = crate::Pkdns::with_client(client.clone())
.get_homeserver_of(user)
.await;
Self::homeserver_for_unbound_session(user, resolution)
}
pub(crate) async fn from_response(
response: Response,
homeserver: Option<PublicKey>,
) -> Result<Self> {
let raw_set_cookies = collect_set_cookies(&response);
let bytes = response.bytes().await?;
let record = CookieSessionRecord::deserialize(&bytes)?;
let user = record.public_key().clone();
let cookie_name = user.z32();
let cookie = raw_set_cookies
.iter()
.filter_map(|raw| cookie::Cookie::parse(raw.clone()).ok())
.find(|c| c.name() == cookie_name)
.map(|c| c.value().to_string());
#[cfg(not(target_arch = "wasm32"))]
{
if cookie.is_none() {
return Err(AuthError::Validation("missing session cookie".into()).into());
}
}
#[cfg(target_arch = "wasm32")]
if cookie.is_none() {
cross_log!(
info,
"Hydrating WASM cookie credential without captured secret \
(browser jar will handle attachment) for {}",
user
);
}
cross_log!(info, "Hydrated cookie credential for {}", user);
Ok(Self::new(user, cookie, record, homeserver))
}
pub(crate) async fn from_auth_token(
token: &AuthToken,
client: &PubkyHttpClient,
homeserver: Option<PublicKey>,
) -> Result<Self> {
cross_log!(
info,
"Establishing new session exchange for {}",
token.public_key()
);
let request = session_request(
client,
Method::POST,
token.public_key(),
homeserver.as_ref(),
)
.await?;
let response = request.body(token.serialize()).send().await?;
let response = check_http_status(response).await?;
cross_log!(
info,
"Session exchange for {} succeeded; constructing credential",
token.public_key()
);
Self::from_response(response, homeserver).await
}
pub(crate) fn cookie_secret(&self) -> Option<&str> {
self.cookie.as_deref()
}
pub(crate) fn cookie_record(&self) -> CookieSessionRecord {
self.record
.read()
.expect("CookieCredential record RwLock poisoned")
.clone()
}
pub(crate) fn replace_record(&self, record: CookieSessionRecord) {
if let Ok(mut r) = self.record.write() {
*r = record;
}
}
}
fn session_resource(user: &PublicKey) -> String {
format!("pubky{}{}", user.z32(), SESSION_PATH)
}
async fn session_request(
client: &PubkyHttpClient,
method: Method,
user: &PublicKey,
homeserver: Option<&PublicKey>,
) -> Result<RequestBuilder> {
if let Some(homeserver) = homeserver {
return client
.cross_request_via_homeserver(method, homeserver, user, SESSION_PATH)
.await;
}
let resolved = resolve_pubky(session_resource(user))?;
client.cross_request(method, resolved).await
}
fn collect_set_cookies(response: &Response) -> Vec<String> {
let mut out = Vec::new();
for val in response.headers().get_all(reqwest::header::SET_COOKIE) {
if let Ok(raw) = std::str::from_utf8(val.as_bytes()) {
out.push(raw.to_owned());
}
}
out
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl SessionCredential for CookieCredential {
fn info(&self) -> SessionInfo {
let record = self
.record
.read()
.expect("CookieCredential record RwLock poisoned");
SessionInfo::new(record.public_key().clone(), record.capabilities().to_vec())
}
async fn signout(&self, client: &PubkyHttpClient) -> Result<()> {
let homeserver = match self.bound_homeserver() {
Some(homeserver) => Some(homeserver),
None => Self::resolve_unbound_homeserver(client, &self.user).await?,
};
let rb = session_request(client, Method::DELETE, &self.user, homeserver.as_ref()).await?;
let rb = self.attach(rb, client).await?;
let response = rb.send().await.map_err(crate::Error::from)?;
check_http_status(response).await?;
Ok(())
}
async fn attach(
&self,
rb: RequestBuilder,
_client: &PubkyHttpClient,
) -> Result<RequestBuilder> {
match &self.cookie {
Some(cookie) => {
let cookie_name = self.user.z32();
Ok(rb.header(reqwest::header::COOKIE, format!("{cookie_name}={cookie}")))
}
None => {
#[cfg(target_arch = "wasm32")]
{
Ok(rb.fetch_credentials_include())
}
#[cfg(not(target_arch = "wasm32"))]
{
Ok(rb)
}
}
}
}
async fn can_attach_to(&self, homeserver: &PublicKey) -> bool {
self.bound_homeserver().as_ref() == Some(homeserver)
}
async fn revalidate(
&self,
client: &PubkyHttpClient,
user: &PublicKey,
) -> Result<Option<SessionInfo>> {
let bound_homeserver = self.bound_homeserver();
let bind_on_success = bound_homeserver.is_none();
let homeserver = match bound_homeserver {
Some(homeserver) => Some(homeserver),
None => Self::resolve_unbound_homeserver(client, user).await?,
};
let rb = session_request(client, Method::GET, user, homeserver.as_ref()).await?;
let rb = self.attach(rb, client).await?;
let response = rb.send().await.map_err(crate::Error::from)?;
if credential_session_missing(&response) {
cross_log!(info, "Cookie session missing on revalidate");
return Ok(None);
}
let response = check_http_status(response).await?;
let bytes = response.bytes().await?;
let record = CookieSessionRecord::deserialize(&bytes)?;
let info = SessionInfo::new(record.public_key().clone(), record.capabilities().to_vec());
self.replace_record(record);
if bind_on_success && let Some(homeserver) = homeserver {
self.set_homeserver(homeserver);
}
Ok(Some(info))
}
fn as_any(&self) -> &dyn Any {
self
}
}
impl PubkySession {
#[must_use]
pub fn from_cookie_credential(client: PubkyHttpClient, credential: CookieCredential) -> Self {
Self::from_credential(client, Arc::new(credential))
}
}
#[cfg(test)]
mod tests {
use super::*;
use pkarr::errors::ResolveError;
use pubky_common::{
capabilities::{Capabilities, Capability},
crypto::Keypair,
};
fn cookie_credential(user: &PublicKey, homeserver: Option<PublicKey>) -> CookieCredential {
let record =
CookieSessionRecord::new(user, Capabilities::from(vec![Capability::root()]), None);
CookieCredential::new(
user.clone(),
Some("cookie-secret".to_string()),
record,
homeserver,
)
}
#[tokio::test]
async fn can_attach_to_only_matches_bound_homeserver() {
let user = Keypair::random().public_key();
let bound = Keypair::random().public_key();
let other = Keypair::random().public_key();
let credential = cookie_credential(&user, Some(bound.clone()));
assert!(credential.can_attach_to(&bound).await);
assert!(!credential.can_attach_to(&other).await);
}
#[tokio::test]
async fn can_attach_to_is_false_until_bound() {
let user = Keypair::random().public_key();
let credential = cookie_credential(&user, None);
let homeserver = Keypair::random().public_key();
assert!(!credential.can_attach_to(&homeserver).await);
credential.set_homeserver(homeserver.clone());
assert!(credential.can_attach_to(&homeserver).await);
assert!(
!credential
.can_attach_to(&Keypair::random().public_key())
.await
);
}
#[test]
fn operational_resolution_error_uses_pubky_url_fallback() {
let user = Keypair::random().public_key();
let resolution = Err(PkarrError::Resolve(ResolveError::NoResponses).into());
let homeserver = CookieCredential::homeserver_for_unbound_session(&user, resolution)
.expect("operational resolution errors should use the fallback");
assert_eq!(homeserver, None);
}
#[test]
fn malformed_homeserver_record_does_not_use_pubky_url_fallback() {
let user = Keypair::random().public_key();
let resolution = Err(PkarrError::InvalidRecord("invalid target".into()).into());
let error = CookieCredential::homeserver_for_unbound_session(&user, resolution)
.expect_err("malformed records must remain visible");
assert!(matches!(
error,
Error::Pkarr(PkarrError::InvalidRecord(message)) if message == "invalid target"
));
}
}