diff --git a/examples/authz/authenticator/src/main.rs b/examples/authz/authenticator/src/main.rs index 44e1a52..8a3a1e3 100644 --- a/examples/authz/authenticator/src/main.rs +++ b/examples/authz/authenticator/src/main.rs @@ -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 === diff --git a/pubky-common/src/auth.rs b/pubky-common/src/auth.rs index be19cef..866fe5e 100644 --- a/pubky-common/src/auth.rs +++ b/pubky-common/src/auth.rs @@ -64,7 +64,7 @@ impl AuthToken { &self.capabilities.0 } - fn verify(bytes: &[u8]) -> Result { + pub fn verify(bytes: &[u8]) -> Result { if bytes[75] > CURRENT_VERSION { return Err(Error::UnknownVersion); } diff --git a/pubky-common/src/capabilities.rs b/pubky-common/src/capabilities.rs index 2cb28fc..3dfe19f 100644 --- a/pubky-common/src/capabilities.rs +++ b/pubky-common/src/capabilities.rs @@ -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, } @@ -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::() ) } @@ -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 :")] + #[error("Capability: Invalid scope: does not start with `/`")] + InvalidScope, + #[error("Capability: Invalid format should be :")] InvalidFormat, #[error("Capability: Invalid Ability")] InvalidAbility, + #[error("Capabilities: Invalid capabilities format")] + InvalidCapabilities, } #[derive(Clone, Default, Debug, PartialEq, Eq)] @@ -160,11 +159,24 @@ impl From for Vec { } } -impl Serialize for Capabilities { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { +impl TryFrom<&str> for Capabilities { + type Error = Error; + + fn try_from(value: &str) -> Result { + 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::>() .join(","); - string.serialize(serializer) + write!(f, "{}", string) + } +} + +impl Serialize for Capabilities { + fn serialize(&self, serializer: S) -> Result + 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], }; diff --git a/pubky-common/src/session.rs b/pubky-common/src/session.rs index eb8ff2b..6a718ac 100644 --- a/pubky-common/src/session.rs +++ b/pubky-common/src/session.rs @@ -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) -> 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(); diff --git a/pubky-homeserver/src/routes/auth.rs b/pubky-homeserver/src/routes/auth.rs index cedf122..c09d1c2 100644 --- a/pubky-homeserver/src/routes/auth.rs +++ b/pubky-homeserver/src/routes/auth.rs @@ -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) } diff --git a/pubky/Cargo.toml b/pubky/Cargo.toml index 00f6c8c..a73210c 100644 --- a/pubky/Cargo.toml +++ b/pubky/Cargo.toml @@ -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 } diff --git a/pubky/src/native.rs b/pubky/src/native.rs index 1e917f8..1b5d0d1 100644 --- a/pubky/src/native.rs +++ b/pubky/src/native.rs @@ -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 { 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) {} } diff --git a/pubky/src/shared/auth.rs b/pubky/src/shared/auth.rs index 66a320f..980ec52 100644 --- a/pubky/src/shared/auth.rs +++ b/pubky/src/shared/auth.rs @@ -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 { 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, + capabilities: &Capabilities, + ) -> Result<(Url, tokio::sync::oneshot::Receiver>)> { + 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::>(); + + 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>( + &self, + keypair: &Keypair, + pubkyauth_url: T, + ) -> Result<()> { + let url: Url = pubkyauth_url.try_into().map_err(|_| Error::InvalidUrl)?; + + let query_params: HashMap = 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::>() + }) + .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 { + 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()); + } } diff --git a/pubky/src/shared/pkarr.rs b/pubky/src/shared/pkarr.rs index 1e7c69b..d01eded 100644 --- a/pubky/src/shared/pkarr.rs +++ b/pubky/src/shared/pkarr.rs @@ -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)] diff --git a/pubky/src/wasm/http.rs b/pubky/src/wasm/http.rs index a845794..61fee29 100644 --- a/pubky/src/wasm/http.rs +++ b/pubky/src/wasm/http.rs @@ -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")