mirror of
https://github.com/aljazceru/pubky-core.git
synced 2026-01-08 08:44:24 +01:00
feat(pubky): add PubkyClient::auth_request and send_auth_token
This commit is contained in:
@@ -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 ===
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user