feat(pubky): add PubkyClient::auth_request and send_auth_token

This commit is contained in:
nazeh
2024-09-05 20:43:16 +03:00
parent a7f70ccb1b
commit f1b65e9dac
10 changed files with 239 additions and 60 deletions

View File

@@ -61,7 +61,7 @@ async fn main() -> Result<()> {
}
for cap in &required_capabilities {
println!(" {} : {:?}", cap.resource, cap.abilities);
println!(" {} : {:?}", cap.scope, cap.abilities);
}
// === Consent form ===

View File

@@ -64,7 +64,7 @@ impl AuthToken {
&self.capabilities.0
}
fn verify(bytes: &[u8]) -> Result<Self, Error> {
pub fn verify(bytes: &[u8]) -> Result<Self, Error> {
if bytes[75] > CURRENT_VERSION {
return Err(Error::UnknownVersion);
}

View File

@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Capability {
pub resource: String,
pub scope: String,
pub abilities: Vec<Ability>,
}
@@ -12,7 +12,7 @@ impl Capability {
/// Create a root [Capability] at the `/` path with all the available [PubkyAbility]
pub fn root() -> Self {
Capability {
resource: "/".to_string(),
scope: "/".to_string(),
abilities: vec![Ability::Read, Ability::Write],
}
}
@@ -20,9 +20,9 @@ impl Capability {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ability {
/// Can read the resource at the specified path (GET requests).
/// Can read the scope at the specified path (GET requests).
Read,
/// Can write to the resource at the specified path (PUT/POST/DELETE requests).
/// Can write to the scope at the specified path (PUT/POST/DELETE requests).
Write,
/// Unknown ability
Unknown(char),
@@ -55,7 +55,7 @@ impl Display for Capability {
write!(
f,
"{}:{}",
self.resource,
self.scope,
self.abilities.iter().map(char::from).collect::<String>()
)
}
@@ -78,7 +78,7 @@ impl TryFrom<&str> for Capability {
}
if !value.starts_with('/') {
return Err(Error::InvalidResource);
return Err(Error::InvalidScope);
}
let abilities_str = value.rsplit(':').next().unwrap_or("");
@@ -96,12 +96,9 @@ impl TryFrom<&str> for Capability {
}
}
let resource = value[0..value.len() - abilities_str.len() - 1].to_string();
let scope = value[0..value.len() - abilities_str.len() - 1].to_string();
Ok(Capability {
resource,
abilities,
})
Ok(Capability { scope, abilities })
}
}
@@ -129,12 +126,14 @@ impl<'de> Deserialize<'de> for Capability {
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
pub enum Error {
#[error("Capability: Invalid resource path: does not start with `/`")]
InvalidResource,
#[error("Capability: Invalid format should be <resource>:<abilities>")]
#[error("Capability: Invalid scope: does not start with `/`")]
InvalidScope,
#[error("Capability: Invalid format should be <scope>:<abilities>")]
InvalidFormat,
#[error("Capability: Invalid Ability")]
InvalidAbility,
#[error("Capabilities: Invalid capabilities format")]
InvalidCapabilities,
}
#[derive(Clone, Default, Debug, PartialEq, Eq)]
@@ -160,11 +159,24 @@ impl From<Capabilities> for Vec<Capability> {
}
}
impl Serialize for Capabilities {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
impl TryFrom<&str> for Capabilities {
type Error = Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let mut caps = vec![];
for s in value.split(',') {
if let Ok(cap) = Capability::try_from(s) {
caps.push(cap);
};
}
Ok(Capabilities(caps))
}
}
impl Display for Capabilities {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let string = self
.0
.iter()
@@ -172,7 +184,16 @@ impl Serialize for Capabilities {
.collect::<Vec<_>>()
.join(",");
string.serialize(serializer)
write!(f, "{}", string)
}
}
impl Serialize for Capabilities {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.to_string().serialize(serializer)
}
}
@@ -202,7 +223,7 @@ mod tests {
#[test]
fn pubky_caps() {
let cap = Capability {
resource: "/pub/pubky.app/".to_string(),
scope: "/pub/pubky.app/".to_string(),
abilities: vec![Ability::Read, Ability::Write],
};

View File

@@ -1,3 +1,4 @@
use pkarr::PublicKey;
use postcard::{from_bytes, to_allocvec};
use serde::{Deserialize, Serialize};
@@ -9,9 +10,10 @@ use crate::{auth::AuthToken, capabilities::Capability, timestamp::Timestamp};
// TODO: add IP address?
// TODO: use https://crates.io/crates/user-agent-parser to parse the session
// and get more informations from the user-agent.
#[derive(Clone, Default, Serialize, Deserialize, Debug, Eq, PartialEq)]
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct Session {
pub version: usize,
pub pubky: PublicKey,
pub created_at: u64,
/// User specified name, defaults to the user-agent.
pub name: String,
@@ -21,18 +23,14 @@ pub struct Session {
impl Session {
pub fn new(token: &AuthToken, user_agent: Option<String>) -> Self {
let mut session = Self {
Self {
version: 0,
pubky: token.pubky().to_owned(),
created_at: Timestamp::now().into_inner(),
..Default::default()
};
session.set_capabilities(token.capabilities().to_vec());
if let Some(user_agent) = user_agent {
session.set_user_agent(user_agent);
capabilities: token.capabilities().to_vec(),
user_agent: user_agent.as_deref().unwrap_or("").to_string(),
name: user_agent.as_deref().unwrap_or("").to_string(),
}
session
}
// === Setters ===
@@ -82,21 +80,33 @@ pub enum Error {
#[cfg(test)]
mod tests {
use crate::crypto::Keypair;
use super::*;
#[test]
fn serialize() {
let keypair = Keypair::from_secret_key(&[0; 32]);
let pubky = keypair.public_key();
let session = Session {
user_agent: "foo".to_string(),
capabilities: vec![Capability::root()],
..Default::default()
created_at: 0,
pubky,
version: 0,
name: "".to_string(),
};
let serialized = session.serialize();
assert_eq!(
serialized,
[0, 0, 0, 3, 102, 111, 111, 1, 4, 47, 58, 114, 119]
[
0, 59, 106, 39, 188, 206, 182, 164, 45, 98, 163, 168, 208, 42, 111, 13, 115, 101,
50, 21, 119, 29, 226, 67, 166, 58, 192, 72, 161, 139, 89, 218, 41, 0, 0, 3, 102,
111, 111, 1, 4, 47, 58, 114, 119
]
);
let deseiralized = Session::deserialize(&serialized).unwrap();

View File

@@ -113,11 +113,13 @@ pub async fn signin(
let session_secret = base32::encode(base32::Alphabet::Crockford, &random_bytes(16));
state.db.tables.sessions.put(
&mut wtxn,
&session_secret,
&Session::new(&token, user_agent.map(|ua| ua.to_string())).serialize(),
)?;
let session = Session::new(&token, user_agent.map(|ua| ua.to_string())).serialize();
state
.db
.tables
.sessions
.put(&mut wtxn, &session_secret, &session)?;
let mut cookie = Cookie::new(public_key.to_string(), session_secret);
@@ -132,7 +134,5 @@ pub async fn signin(
wtxn.commit()?;
// TODO: return session to save extra call?
Ok(())
Ok(session)
}

View File

@@ -15,10 +15,11 @@ thiserror = "1.0.62"
wasm-bindgen = "0.2.92"
url = "2.5.2"
bytes = "^1.7.1"
base64 = "0.22.1"
pubky-common = { version = "0.1.0", path = "../pubky-common" }
pkarr = { workspace = true, features = ["async"] }
base64 = "0.22.1"
tokio = { version = "1.37.0", features = ["full"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
reqwest = { version = "0.12.5", features = ["cookies", "rustls-tls"], default-features = false }

View File

@@ -128,7 +128,7 @@ impl PubkyClient {
}
/// Signin to a homeserver.
pub async fn signin(&self, keypair: &Keypair) -> Result<()> {
pub async fn signin(&self, keypair: &Keypair) -> Result<Session> {
self.inner_signin(keypair).await
}
@@ -192,6 +192,6 @@ impl PubkyClient {
self.http.request(method, url)
}
pub(crate) fn store_session(&self, _: Response) {}
pub(crate) fn store_session(&self, _: &Response) {}
pub(crate) fn remove_session(&self, _: &PublicKey) {}
}

View File

@@ -1,16 +1,22 @@
use std::collections::HashMap;
use reqwest::{Method, StatusCode};
use base64::{alphabet::URL_SAFE, engine::general_purpose::NO_PAD, Engine};
use pkarr::{Keypair, PublicKey};
use pubky_common::{
auth::AuthToken,
capabilities::Capability,
crypto::{decrypt, encrypt, hash},
capabilities::{Capabilities, Capability},
crypto::{decrypt, encrypt, hash, random_bytes},
session::Session,
};
use tokio::sync::oneshot;
use url::Url;
use crate::{error::Result, PubkyClient};
use crate::{
error::{Error, Result},
PubkyClient,
};
use super::pkarr::Endpoint;
@@ -38,7 +44,7 @@ impl PubkyClient {
.send()
.await?;
self.store_session(response);
self.store_session(&response);
self.publish_pubky_homeserver(keypair, &homeserver).await?;
@@ -83,12 +89,66 @@ impl PubkyClient {
}
/// Signin to a homeserver.
pub(crate) async fn inner_signin(&self, keypair: &Keypair) -> Result<()> {
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
}
/// Return `pubkyauth://` url and wait for the incoming [AuthToken]
/// verifying that AuthToken, and if capabilities were requested, signing in to
/// the Pubky's homeserver and returning the [Session] information.
pub fn auth_request(
&self,
relay: impl TryInto<Url>,
capabilities: &Capabilities,
) -> Result<(Url, tokio::sync::oneshot::Receiver<Option<Session>>)> {
let mut relay: Url = relay
.try_into()
.map_err(|_| Error::Generic("Invalid relay Url".into()))?;
let engine = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD);
let client_secret: [u8; 32] = random_bytes(32).try_into().unwrap();
let pubkyauth_url = Url::parse(&format!(
"pubkyauth:///?caps={capabilities}&secret={}&relay={relay}",
engine.encode(client_secret)
))?;
// Add channel id
let mut segments = relay.path_segments_mut().map_err(|_| {
Error::Generic("Could not add channel_id to http relay base url".into())
})?;
segments.push(&engine.encode(hash(&client_secret).as_bytes()));
drop(segments);
let (tx, rx) = oneshot::channel::<Option<Session>>();
let this = self.clone();
tokio::spawn(async move {
let response = this.http.request(Method::GET, relay).send().await?;
let encrypted_token = response.bytes().await?;
let token_bytes = decrypt(&encrypted_token, &client_secret)?;
let token = AuthToken::verify(&token_bytes)?;
let to_send = if token.capabilities().is_empty() {
None
} else {
let session = this.signin_with_authtoken(&token).await?;
Some(session)
};
tx.send(to_send)
.map_err(|_| Error::Generic("Failed to send the session after signing in with token, since the receiver is dropped".into()))?;
Ok::<(), Error>(())
});
Ok((pubkyauth_url, rx))
}
pub async fn authorize(
&self,
keypair: &Keypair,
@@ -107,7 +167,6 @@ impl PubkyClient {
let mut callback = relay.clone();
let mut path_segments = callback.path_segments_mut().unwrap();
path_segments.push(&channel_id);
drop(path_segments);
self.request(Method::POST, callback)
@@ -118,6 +177,45 @@ impl PubkyClient {
Ok(())
}
pub async fn send_auth_token<T: TryInto<Url>>(
&self,
keypair: &Keypair,
pubkyauth_url: T,
) -> Result<()> {
let url: Url = pubkyauth_url.try_into().map_err(|_| Error::InvalidUrl)?;
let query_params: HashMap<String, String> = 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();
self.authorize(keypair, capabilities, client_secret, &relay)
.await
}
pub async fn inner_third_party_signin(
&self,
encrypted_token: &[u8],
@@ -131,10 +229,10 @@ impl PubkyClient {
Ok(token.pubky().to_owned())
}
async fn signin_with_authtoken(&self, token: &AuthToken) -> Result<()> {
let Endpoint { mut url, .. } = self.resolve_pubky_homeserver(token.pubky()).await?;
async fn signin_with_authtoken(&self, token: &AuthToken) -> Result<Session> {
let mut url = Url::parse(&format!("https://{}/session", token.pubky()))?;
url.set_path("/session");
self.resolve_url(&mut url).await?;
let response = self
.request(Method::POST, url)
@@ -142,9 +240,11 @@ impl PubkyClient {
.send()
.await?;
self.store_session(response);
self.store_session(&response);
Ok(())
let bytes = response.bytes().await?;
Ok(Session::deserialize(&bytes)?)
}
}
@@ -154,8 +254,9 @@ mod tests {
use crate::*;
use pkarr::{mainline::Testnet, Keypair};
use pubky_common::capabilities::Capability;
use pubky_common::capabilities::{Capabilities, Capability};
use pubky_homeserver::Homeserver;
use url::Url;
#[tokio::test]
async fn basic_authn() {
@@ -196,4 +297,38 @@ mod tests {
assert!(session.capabilities.contains(&Capability::root()));
}
}
#[tokio::test]
async fn authz() {
let testnet = Testnet::new(10);
let server = Homeserver::start_test(&testnet).await.unwrap();
let keypair = Keypair::random();
// Third party app side
let capabilities: Capabilities = "/pub/pubky.app/:rw,/prv/foo.bar/file:rw"
.try_into()
.unwrap();
let client = PubkyClient::test(&testnet);
let (pubkyauth_url, pubkyauth_response) = client
.auth_request("https://demo.httprelay.io/link", &capabilities)
.unwrap();
// Authenticator side
{
let client = PubkyClient::test(&testnet);
client.signup(&keypair, &server.public_key()).await.unwrap();
client
.send_auth_token(&keypair, pubkyauth_url)
.await
.unwrap();
}
let session = pubkyauth_response.await.unwrap().unwrap();
assert_eq!(session.capabilities, capabilities.0);
assert_eq!(session.pubky, keypair.public_key());
}
}

View File

@@ -157,6 +157,18 @@ impl PubkyClient {
Err(Error::ResolveEndpoint(original_target.into()))
}
pub(crate) async fn resolve_url(&self, url: &mut Url) -> Result<()> {
if let Some(Ok(pubky)) = url.host_str().map(PublicKey::try_from) {
let Endpoint { url: x, .. } = self.resolve_endpoint(&format!("_pubky.{pubky}")).await?;
url.set_host(x.host_str())?;
url.set_port(x.port()).expect("should work!");
url.set_scheme(x.scheme()).expect("should work!");
};
Ok(())
}
}
#[derive(Debug)]

View File

@@ -16,7 +16,7 @@ impl PubkyClient {
// Support cookies for nodejs
pub(crate) fn store_session(&self, response: Response) {
pub(crate) fn store_session(&self, response: &Response) {
if let Some(cookie) = response
.headers()
.get("set-cookie")