mirror of
https://github.com/aljazceru/pubky-core.git
synced 2026-01-10 17:54:21 +01:00
443 lines
13 KiB
Rust
443 lines
13 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use base64::{alphabet::URL_SAFE, engine::general_purpose::NO_PAD, Engine};
|
|
use reqwest::{IntoUrl, Method, StatusCode};
|
|
use url::Url;
|
|
|
|
use pkarr::{Keypair, PublicKey};
|
|
use pubky_common::{
|
|
auth::AuthToken,
|
|
capabilities::{Capabilities, Capability},
|
|
crypto::{decrypt, encrypt, hash, random_bytes},
|
|
session::Session,
|
|
};
|
|
|
|
use anyhow::Result;
|
|
|
|
use crate::Client;
|
|
|
|
impl Client {
|
|
/// Signup to a homeserver and update Pkarr accordingly.
|
|
///
|
|
/// The homeserver is a Pkarr domain name, where the TLD is a Pkarr public key
|
|
/// for example "pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy"
|
|
pub(crate) async fn inner_signup(
|
|
&self,
|
|
keypair: &Keypair,
|
|
homeserver: &PublicKey,
|
|
) -> Result<Session> {
|
|
let response = self
|
|
.inner_request(Method::POST, format!("https://{}/signup", homeserver))
|
|
.await
|
|
.body(AuthToken::sign(keypair, vec![Capability::root()]).serialize())
|
|
.send()
|
|
.await?
|
|
.error_for_status()?;
|
|
|
|
self.publish_homeserver(keypair, &homeserver.to_string())
|
|
.await?;
|
|
|
|
// Store the cookie to the correct URL.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
self.cookie_store
|
|
.store_session_after_signup(&response, &keypair.public_key());
|
|
|
|
let bytes = response.bytes().await?;
|
|
|
|
Ok(Session::deserialize(&bytes)?)
|
|
}
|
|
|
|
/// Check the current sesison for a given Pubky in its homeserver.
|
|
///
|
|
/// Returns None if not signed in, or [reqwest::Error]
|
|
/// if the response has any other `>=404` status code.
|
|
pub(crate) async fn inner_session(&self, pubky: &PublicKey) -> Result<Option<Session>> {
|
|
let res = self
|
|
.inner_request(Method::GET, format!("pubky://{}/session", pubky))
|
|
.await
|
|
.send()
|
|
.await?;
|
|
|
|
if res.status() == StatusCode::NOT_FOUND {
|
|
return Ok(None);
|
|
}
|
|
|
|
res.error_for_status_ref()?;
|
|
|
|
let bytes = res.bytes().await?;
|
|
|
|
Ok(Some(Session::deserialize(&bytes)?))
|
|
}
|
|
|
|
/// Signout from a homeserver.
|
|
pub(crate) async fn inner_signout(&self, pubky: &PublicKey) -> Result<()> {
|
|
self.inner_request(Method::DELETE, format!("pubky://{}/session", pubky))
|
|
.await
|
|
.send()
|
|
.await?
|
|
.error_for_status()?;
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
self.cookie_store.delete_session_after_signout(pubky);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Signin to a homeserver.
|
|
pub(crate) async fn inner_signin(&self, keypair: &Keypair) -> Result<Session> {
|
|
let token = AuthToken::sign(keypair, vec![Capability::root()]);
|
|
|
|
self.signin_with_authtoken(&token).await
|
|
}
|
|
|
|
pub(crate) async fn inner_send_auth_token<T: IntoUrl>(
|
|
&self,
|
|
keypair: &Keypair,
|
|
pubkyauth_url: T,
|
|
) -> Result<()> {
|
|
let pubkyauth_url = Url::parse(
|
|
pubkyauth_url
|
|
.as_str()
|
|
.replace("pubkyauth_url", "http")
|
|
.as_str(),
|
|
)?;
|
|
|
|
let query_params: HashMap<String, String> =
|
|
pubkyauth_url.query_pairs().into_owned().collect();
|
|
|
|
let relay = query_params
|
|
.get("relay")
|
|
.map(|r| url::Url::parse(r).expect("Relay query param to be valid URL"))
|
|
.expect("Missing relay query param");
|
|
|
|
let client_secret = query_params
|
|
.get("secret")
|
|
.map(|s| {
|
|
let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD);
|
|
let bytes = engine.decode(s).expect("invalid client_secret");
|
|
let arr: [u8; 32] = bytes.try_into().expect("invalid client_secret");
|
|
|
|
arr
|
|
})
|
|
.expect("Missing client secret");
|
|
|
|
let capabilities = query_params
|
|
.get("caps")
|
|
.map(|caps_string| {
|
|
caps_string
|
|
.split(',')
|
|
.filter_map(|cap| Capability::try_from(cap).ok())
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let token = AuthToken::sign(keypair, capabilities);
|
|
|
|
let encrypted_token = encrypt(&token.serialize(), &client_secret)?;
|
|
|
|
let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD);
|
|
|
|
let mut callback_url = relay.clone();
|
|
let mut path_segments = callback_url.path_segments_mut().unwrap();
|
|
path_segments.pop_if_empty();
|
|
let channel_id = engine.encode(hash(&client_secret).as_bytes());
|
|
path_segments.push(&channel_id);
|
|
drop(path_segments);
|
|
|
|
self.inner_request(Method::POST, callback_url)
|
|
.await
|
|
.body(encrypted_token)
|
|
.send()
|
|
.await?
|
|
.error_for_status()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn signin_with_authtoken(&self, token: &AuthToken) -> Result<Session> {
|
|
let response = self
|
|
.inner_request(Method::POST, format!("pubky://{}/session", token.pubky()))
|
|
.await
|
|
.body(token.serialize())
|
|
.send()
|
|
.await?
|
|
.error_for_status()?;
|
|
|
|
let bytes = response.bytes().await?;
|
|
|
|
Ok(Session::deserialize(&bytes)?)
|
|
}
|
|
|
|
pub(crate) fn create_auth_request(
|
|
&self,
|
|
relay: &mut Url,
|
|
capabilities: &Capabilities,
|
|
) -> Result<(Url, [u8; 32])> {
|
|
let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD);
|
|
|
|
let client_secret: [u8; 32] = random_bytes::<32>();
|
|
|
|
let pubkyauth_url = Url::parse(&format!(
|
|
"pubkyauth:///?caps={capabilities}&secret={}&relay={relay}",
|
|
engine.encode(client_secret)
|
|
))?;
|
|
|
|
let mut segments = relay
|
|
.path_segments_mut()
|
|
.map_err(|_| anyhow::anyhow!("Invalid relay"))?;
|
|
|
|
// remove trailing slash if any.
|
|
segments.pop_if_empty();
|
|
let channel_id = &engine.encode(hash(&client_secret).as_bytes());
|
|
segments.push(channel_id);
|
|
drop(segments);
|
|
|
|
Ok((pubkyauth_url, client_secret))
|
|
}
|
|
|
|
pub(crate) async fn subscribe_to_auth_response(
|
|
&self,
|
|
relay: Url,
|
|
client_secret: &[u8; 32],
|
|
) -> Result<PublicKey> {
|
|
// TODO: use a clearnet client.
|
|
let response = reqwest::get(relay).await?;
|
|
let encrypted_token = response.bytes().await?;
|
|
let token_bytes = decrypt(&encrypted_token, client_secret)
|
|
.map_err(|e| anyhow::anyhow!("Got invalid token: {e}"))?;
|
|
let token = AuthToken::verify(&token_bytes)?;
|
|
|
|
if !token.capabilities().is_empty() {
|
|
self.signin_with_authtoken(&token).await?;
|
|
}
|
|
|
|
Ok(token.pubky().clone())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
|
|
use std::net::SocketAddr;
|
|
|
|
use crate::*;
|
|
|
|
use pkarr::{mainline::Testnet, Keypair};
|
|
use pubky_common::capabilities::{Capabilities, Capability};
|
|
use pubky_homeserver::Homeserver;
|
|
use reqwest::StatusCode;
|
|
|
|
#[tokio::test]
|
|
async fn basic_authn() {
|
|
let testnet = Testnet::new(10).unwrap();
|
|
let server = Homeserver::start_test(&testnet).await.unwrap();
|
|
|
|
let client = Client::test(&testnet);
|
|
|
|
let keypair = Keypair::random();
|
|
|
|
client.signup(&keypair, &server.public_key()).await.unwrap();
|
|
|
|
let session = client
|
|
.session(&keypair.public_key())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert!(session.capabilities().contains(&Capability::root()));
|
|
|
|
client.signout(&keypair.public_key()).await.unwrap();
|
|
|
|
{
|
|
let session = client.session(&keypair.public_key()).await.unwrap();
|
|
|
|
assert!(session.is_none());
|
|
}
|
|
|
|
client.signin(&keypair).await.unwrap();
|
|
|
|
{
|
|
let session = client
|
|
.session(&keypair.public_key())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(session.pubky(), &keypair.public_key());
|
|
assert!(session.capabilities().contains(&Capability::root()));
|
|
}
|
|
}
|
|
|
|
async fn http_relay_server() -> Option<SocketAddr> {
|
|
use axum::{
|
|
body::{Body, Bytes},
|
|
extract::{Path, State},
|
|
response::IntoResponse,
|
|
routing::get,
|
|
Router,
|
|
};
|
|
use axum_server::Handle;
|
|
use futures_util::stream::StreamExt;
|
|
use std::{
|
|
collections::HashMap,
|
|
net::SocketAddr,
|
|
sync::{Arc, Mutex},
|
|
};
|
|
use tokio::sync::Notify;
|
|
|
|
// Shared state to store GET requests and their notifications
|
|
type SharedState = Arc<Mutex<HashMap<String, (Vec<u8>, Arc<Notify>)>>>;
|
|
let shared_state: SharedState = Arc::new(Mutex::new(HashMap::new()));
|
|
|
|
// Handler for the GET endpoint
|
|
async fn subscribe(
|
|
Path(id): Path<String>,
|
|
State(state): State<SharedState>,
|
|
) -> impl IntoResponse {
|
|
// Create a notification for this ID
|
|
let notify = Arc::new(Notify::new());
|
|
|
|
{
|
|
let mut map = state.lock().unwrap();
|
|
|
|
// Store the notification and return it when POST arrives
|
|
map.entry(id.clone())
|
|
.or_insert_with(|| (vec![], notify.clone()));
|
|
}
|
|
|
|
notify.notified().await;
|
|
|
|
// Respond with the data stored for this ID
|
|
let map = state.lock().unwrap();
|
|
if let Some((data, _)) = map.get(&id) {
|
|
Bytes::from(data.clone()).into_response()
|
|
} else {
|
|
(axum::http::StatusCode::NOT_FOUND, "Not Found").into_response()
|
|
}
|
|
}
|
|
|
|
// Handler for the POST endpoint
|
|
async fn publish(
|
|
Path(id): Path<String>,
|
|
State(state): State<SharedState>,
|
|
body: Body,
|
|
) -> impl IntoResponse {
|
|
// Aggregate the body into bytes
|
|
let mut stream = body.into_data_stream();
|
|
let mut bytes = vec![];
|
|
while let Some(next) = stream.next().await {
|
|
let chunk = next.map_err(|e| e.to_string()).unwrap();
|
|
bytes.extend_from_slice(&chunk);
|
|
}
|
|
|
|
// Notify any waiting GET request for this ID
|
|
let mut map = state.lock().unwrap();
|
|
if let Some((storage, notify)) = map.get_mut(&id) {
|
|
*storage = bytes;
|
|
notify.notify_one();
|
|
Ok(())
|
|
} else {
|
|
Err((
|
|
axum::http::StatusCode::NOT_FOUND,
|
|
"No waiting GET request for this ID",
|
|
))
|
|
}
|
|
}
|
|
|
|
let app = Router::new()
|
|
.route("/:id", get(subscribe).post(publish))
|
|
.with_state(shared_state);
|
|
|
|
let handle = Handle::new();
|
|
|
|
let cloned = handle.clone();
|
|
tokio::spawn(async {
|
|
axum_server::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
|
|
.handle(cloned)
|
|
.serve(app.into_make_service())
|
|
.await
|
|
.unwrap();
|
|
});
|
|
|
|
handle.listening().await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn authz() {
|
|
let testnet = Testnet::new(10).unwrap();
|
|
let server = Homeserver::start_test(&testnet).await.unwrap();
|
|
|
|
let http_relay_url = http_relay_server().await.unwrap();
|
|
|
|
let keypair = Keypair::random();
|
|
let pubky = keypair.public_key();
|
|
|
|
// Third party app side
|
|
let capabilities: Capabilities =
|
|
"/pub/pubky.app/:rw,/pub/foo.bar/file:r".try_into().unwrap();
|
|
let client = Client::test(&testnet);
|
|
|
|
let (pubkyauth_url, pubkyauth_response) = client
|
|
.auth_request(
|
|
&format!("http://{}", http_relay_url.to_string()),
|
|
&capabilities,
|
|
)
|
|
.unwrap();
|
|
|
|
// Authenticator side
|
|
{
|
|
let client = Client::test(&testnet);
|
|
|
|
client.signup(&keypair, &server.public_key()).await.unwrap();
|
|
|
|
client
|
|
.send_auth_token(&keypair, pubkyauth_url)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
let public_key = pubkyauth_response
|
|
.await
|
|
.expect("sender to not be dropped")
|
|
.unwrap();
|
|
|
|
assert_eq!(&public_key, &pubky);
|
|
|
|
let session = client.session(&pubky).await.unwrap().unwrap();
|
|
assert_eq!(session.capabilities(), &capabilities.0);
|
|
|
|
// Test access control enforcement
|
|
|
|
client
|
|
.put(format!("pubky://{pubky}/pub/pubky.app/foo"))
|
|
.body(vec![])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.error_for_status()
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
client
|
|
.put(format!("pubky://{pubky}/pub/pubky.app"))
|
|
.body(vec![])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
|
|
assert_eq!(
|
|
client
|
|
.put(format!("pubky://{pubky}/pub/foo.bar/file"))
|
|
.body(vec![])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
}
|
|
}
|