diff --git a/e2e/src/tests/auth.rs b/e2e/src/tests/auth.rs index 296265753..06266bb45 100644 --- a/e2e/src/tests/auth.rs +++ b/e2e/src/tests/auth.rs @@ -221,7 +221,10 @@ async fn signup_authz() { }; assert_eq!(signup_deep_link.capabilities(), &caps); assert_eq!( - signup_deep_link.relay().as_str(), + signup_deep_link + .relay() + .expect("signup deep link should include relay") + .as_str(), testnet.http_relay().local_link_url().as_str() ); assert_eq!(signup_deep_link.homeserver(), &server.public_key()); @@ -274,6 +277,49 @@ async fn signup_authz() { ); } +#[tokio::test] +#[pubky_testnet::test] +async fn signup_via_direct_deeplink() { + let testnet = build_full_testnet().await; + let server = testnet.homeserver_app(); + let pubky = testnet.sdk().unwrap(); + + let signer = pubky.signer(Keypair::random()); + let deeplink = format!("pubkyauth://signup?hs={}", server.public_key().z32()); + + signer.approve_auth(&deeplink).await.unwrap(); + + let session = signer.signin().await.unwrap(); + assert_eq!(session.info().public_key(), &signer.public_key()); +} + +#[tokio::test] +#[pubky_testnet::test] +async fn signup_via_direct_deeplink_with_token() { + let testnet = build_full_testnet().await; + let server = testnet.homeserver_app(); + let pubky = testnet.sdk().unwrap(); + + let token = server + .admin_server() + .expect("admin server should be enabled") + .create_signup_token() + .await + .unwrap(); + + let signer = pubky.signer(Keypair::random()); + let deeplink = format!( + "pubkyauth://signup?hs={}&st={}", + server.public_key().z32(), + token + ); + + signer.approve_auth(&deeplink).await.unwrap(); + + let session = signer.signin().await.unwrap(); + assert_eq!(session.info().public_key(), &signer.public_key()); +} + #[tokio::test] #[pubky_testnet::test] async fn persist_and_restore_info() { diff --git a/pubky-sdk/bindings/js/pkg/tests/auth.ts b/pubky-sdk/bindings/js/pkg/tests/auth.ts index e28cf3f0a..9da320a52 100644 --- a/pubky-sdk/bindings/js/pkg/tests/auth.ts +++ b/pubky-sdk/bindings/js/pkg/tests/auth.ts @@ -98,6 +98,45 @@ test("Auth: 3rd party signin", async (t) => { t.end(); }); +test("Auth: direct signup deeplink", async (t) => { + const sdk = Pubky.testnet(); + + const signer = sdk.signer(Keypair.random()); + const deeplink = `pubkyauth://signup?hs=${HOMESERVER_PUBLICKEY.z32()}`; + + await signer.approveAuthRequest(deeplink); + + const session = await signer.signin(); + t.equal( + session.info.publicKey.z32(), + signer.publicKey.z32(), + "session belongs to expected user", + ); + + t.end(); +}); + +test("Auth: direct signup deeplink with token", async (t) => { + const sdk = Pubky.testnet(); + + const signupToken = await createSignupToken(); + const signer = sdk.signer(Keypair.random()); + const deeplink = `pubkyauth://signup?hs=${HOMESERVER_PUBLICKEY.z32()}&st=${encodeURIComponent( + signupToken, + )}`; + + await signer.approveAuthRequest(deeplink); + + const session = await signer.signin(); + t.equal( + session.info.publicKey.z32(), + signer.publicKey.z32(), + "session belongs to expected user", + ); + + t.end(); +}); + test("startAuthFlow: rejects malformed capabilities; normalizes valid; allows empty", async (t) => { const sdk = Pubky.testnet(); // uses local testnet mapping so URLs are resolvable in-node diff --git a/pubky-sdk/bindings/js/pkg/tests/deep_links.ts b/pubky-sdk/bindings/js/pkg/tests/deep_links.ts index dc91e786b..15ca21adb 100644 --- a/pubky-sdk/bindings/js/pkg/tests/deep_links.ts +++ b/pubky-sdk/bindings/js/pkg/tests/deep_links.ts @@ -36,12 +36,19 @@ test("signup deep link valid", async (t) => { const deepLink = SignupDeepLink.parse(url); t.equal(deepLink.capabilities, "/pub/pubky.app/:rw"); t.equal(deepLink.baseRelayUrl, TESTNET_HTTP_RELAY); - t.deepEqual(deepLink.secret, new Uint8Array([146, 169, 220, 120, 67, 32, 172, 212, 12, 255, 24, 180, 234, 132, 23, 140, 13, 220, 36, 117, 255, 69, 9, 176, 212, 22, 58, 36, 77, 91, 177, 239])); + t.deepEqual( + deepLink.secret, + new Uint8Array([ + 146, 169, 220, 120, 67, 32, 172, 212, 12, 255, 24, 180, 234, 132, 23, + 140, 13, 220, 36, 117, 255, 69, 9, 176, 212, 22, 58, 36, 77, 91, 177, + 239, + ]), + ); t.equal(deepLink.homeserver.z32(), HOMESERVER_PUBLICKEY.z32()); t.equal(deepLink.signupToken, "1234567890"); - t.equal(deepLink.toString(), url); + const expectedUrl = "pubkyauth://signup?hs=8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo&st=1234567890&relay=http://localhost:15412/link&secret=kqnceEMgrNQM_xi06oQXjA3cJHX_RQmw1BY6JE1bse8&caps=/pub/pubky.app/:rw"; + t.equal(deepLink.toString(), expectedUrl); t.end(); }); - diff --git a/pubky-sdk/bindings/js/src/actors/deep_links/signup.rs b/pubky-sdk/bindings/js/src/actors/deep_links/signup.rs index ac37c1eee..4e3d71b11 100644 --- a/pubky-sdk/bindings/js/src/actors/deep_links/signup.rs +++ b/pubky-sdk/bindings/js/src/actors/deep_links/signup.rs @@ -31,13 +31,15 @@ impl SignupDeepLink { } #[wasm_bindgen(js_name = "baseRelayUrl", getter)] - pub fn base_relay_url(&self) -> String { - self.0.relay().to_string() + pub fn base_relay_url(&self) -> Option { + self.0.relay().map(|relay| relay.to_string()) } #[wasm_bindgen(getter)] - pub fn secret(&self) -> Uint8Array { - Uint8Array::from(self.0.secret().as_ref()) + pub fn secret(&self) -> Option { + self.0 + .secret() + .map(|secret| Uint8Array::from(secret.as_ref())) } #[wasm_bindgen(getter)] diff --git a/pubky-sdk/src/actors/auth/deep_links/signup.rs b/pubky-sdk/src/actors/auth/deep_links/signup.rs index e17c8be02..ae7befd8f 100644 --- a/pubky-sdk/src/actors/auth/deep_links/signup.rs +++ b/pubky-sdk/src/actors/auth/deep_links/signup.rs @@ -1,6 +1,10 @@ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use pubky_common::capabilities::Capabilities; -use std::{fmt::Display, str::FromStr}; +use std::{ + collections::HashMap, + fmt::{Display, Write}, + str::FromStr, +}; use url::Url; use crate::PublicKey; @@ -8,12 +12,14 @@ use crate::actors::auth::deep_links::{DEEP_LINK_SCHEMES, error::DeepLinkParseErr /// A deep link for signing up to a Pubky homeserver. /// Supported formats: -/// - +/// - +/// - #[derive(Debug, Clone, PartialEq, Eq)] pub struct SignupDeepLink { capabilities: Capabilities, - relay: Url, - secret: [u8; 32], + caps_in_url: bool, + relay: Option, + secret: Option<[u8; 32]>, homeserver: PublicKey, signup_token: Option, } @@ -37,8 +43,9 @@ impl SignupDeepLink { ) -> Self { Self { capabilities, - relay, - secret, + caps_in_url: true, + relay: Some(relay), + secret: Some(secret), homeserver, signup_token, } @@ -51,14 +58,23 @@ impl SignupDeepLink { /// Get the relay for the signup flow. #[must_use] - pub fn relay(&self) -> &Url { - &self.relay + pub fn relay(&self) -> Option<&Url> { + self.relay.as_ref() } /// Get the secret for the signup flow. #[must_use] - pub fn secret(&self) -> &[u8; 32] { - &self.secret + pub fn secret(&self) -> Option<&[u8; 32]> { + self.secret.as_ref() + } + + /// Returns true if this deep link represents a direct signup flow. + /// + /// Direct signup links omit relay/secret and only include the homeserver + /// and optional signup token. + #[must_use] + pub fn is_direct_signup(&self) -> bool { + self.relay.is_none() && self.secret.is_none() } /// Get the homeserver for the signup flow. @@ -76,17 +92,22 @@ impl SignupDeepLink { impl Display for SignupDeepLink { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let url = format!( - "pubkyauth://signup?caps={}&relay={}&secret={}&hs={}", - self.capabilities, - self.relay, - URL_SAFE_NO_PAD.encode(self.secret), - self.homeserver.z32() - ); - write!(f, "{url}")?; + // Canonical query ordering: hs, st, relay, secret, caps. + let mut url = "pubkyauth://signup?".to_string(); + write!(&mut url, "hs={}", self.homeserver.z32())?; if let Some(signup_token) = self.signup_token.as_ref() { - write!(f, "&st={signup_token}")?; + write!(&mut url, "&st={signup_token}")?; + } + if let Some(relay) = self.relay.as_ref() { + write!(&mut url, "&relay={relay}")?; + } + if let Some(secret) = self.secret.as_ref() { + write!(&mut url, "&secret={}", URL_SAFE_NO_PAD.encode(secret))?; } + if self.caps_in_url { + write!(&mut url, "&caps={}", self.capabilities)?; + } + write!(f, "{url}")?; Ok(()) } } @@ -103,59 +124,64 @@ impl FromStr for SignupDeepLink { if intent != "signup" { return Err(DeepLinkParseError::InvalidIntent("signup")); } - let raw_caps = url - .query_pairs() - .find(|(key, _)| key == "caps") - .ok_or(DeepLinkParseError::MissingQueryParameter("caps"))? - .1 - .to_string(); - let capabilities: Capabilities = raw_caps - .as_str() - .try_into() - .map_err(|e| DeepLinkParseError::InvalidQueryParameter("caps", Box::new(e)))?; - - let raw_relay = url - .query_pairs() - .find(|(key, _)| key == "relay") - .ok_or(DeepLinkParseError::MissingQueryParameter("relay"))? - .1 - .to_string(); - let relay = Url::parse(&raw_relay) - .map_err(|e| DeepLinkParseError::InvalidQueryParameter("relay", Box::new(e)))?; - - let raw_secret = url - .query_pairs() - .find(|(key, _)| key == "secret") - .ok_or(DeepLinkParseError::MissingQueryParameter("secret"))? - .1 - .to_string(); - let secret = URL_SAFE_NO_PAD - .decode(raw_secret.as_str()) - .map_err(|e| DeepLinkParseError::InvalidQueryParameter("secret", Box::new(e)))?; - let secret: [u8; 32] = secret.try_into().map_err(|e: Vec| { - let msg = format!("Expected 32 bytes, got {}", e.len()); - DeepLinkParseError::InvalidQueryParameter( - "secret", - Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, msg)), - ) - })?; - - let raw_homeserver = url - .query_pairs() - .find(|(key, _)| key == "hs") - .ok_or(DeepLinkParseError::MissingQueryParameter("hs"))? - .1 - .to_string(); + let mut query_params: HashMap = HashMap::new(); + for (key, value) in url.query_pairs() { + query_params + .entry(key.to_string()) + .or_insert_with(|| value.to_string()); + } + let raw_caps = query_params.get("caps").cloned(); + let (capabilities, caps_in_url) = match raw_caps { + Some(raw_caps) => ( + raw_caps + .as_str() + .try_into() + .map_err(|e| DeepLinkParseError::InvalidQueryParameter("caps", Box::new(e)))?, + true, + ), + None => (Capabilities::default(), false), + }; + + let raw_relay = query_params.get("relay").cloned(); + let raw_secret = query_params.get("secret").cloned(); + + let (relay, secret) = match (raw_relay, raw_secret) { + (Some(raw_relay), Some(raw_secret)) => { + let relay = Url::parse(&raw_relay) + .map_err(|e| DeepLinkParseError::InvalidQueryParameter("relay", Box::new(e)))?; + let secret = URL_SAFE_NO_PAD.decode(raw_secret.as_str()).map_err(|e| { + DeepLinkParseError::InvalidQueryParameter("secret", Box::new(e)) + })?; + let secret: [u8; 32] = secret.try_into().map_err(|e: Vec| { + let msg = format!("Expected 32 bytes, got {}", e.len()); + DeepLinkParseError::InvalidQueryParameter( + "secret", + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, msg)), + ) + })?; + (Some(relay), Some(secret)) + } + (None, None) => (None, None), + (None, Some(_)) => { + return Err(DeepLinkParseError::MissingQueryParameter("relay")); + } + (Some(_), None) => { + return Err(DeepLinkParseError::MissingQueryParameter("secret")); + } + }; + + let raw_homeserver = query_params + .get("hs") + .cloned() + .ok_or(DeepLinkParseError::MissingQueryParameter("hs"))?; let homeserver = PublicKey::try_from_z32(raw_homeserver.as_str()) .map_err(|e| DeepLinkParseError::InvalidQueryParameter("hs", Box::new(e)))?; - let signup_token = url - .query_pairs() - .find(|(key, _)| key == "st") - .map(|(_, value)| value.to_string()); + let signup_token = query_params.get("st").cloned(); Ok(SignupDeepLink { capabilities, + caps_in_url, relay, secret, homeserver, @@ -195,11 +221,11 @@ mod tests { assert_eq!( deep_link_str, format!( - "pubkyauth://signup?caps={}&relay={}&secret={}&hs={}", - capabilities, + "pubkyauth://signup?hs={}&relay={}&secret={}&caps={}", + homeserver.z32(), relay, URL_SAFE_NO_PAD.encode(secret), - homeserver.z32() + capabilities ) ); let deep_link_parsed = SignupDeepLink::from_str(&deep_link_str).unwrap(); @@ -228,15 +254,26 @@ mod tests { assert_eq!( deep_link_str, format!( - "pubkyauth://signup?caps={}&relay={}&secret={}&hs={}&st={}", - capabilities, + "pubkyauth://signup?hs={}&st={}&relay={}&secret={}&caps={}", + homeserver.z32(), + signup_token, relay, URL_SAFE_NO_PAD.encode(secret), - homeserver.z32(), - signup_token + capabilities ) ); let deep_link_parsed = SignupDeepLink::from_str(&deep_link_str).unwrap(); assert_eq!(deep_link_parsed, deep_link); } + + #[test] + fn test_signup_deep_link_parse_direct() { + let homeserver = + PublicKey::from_str("5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo").unwrap(); + let deep_link_str = format!("pubkyauth://signup?hs={}", homeserver.z32()); + let deep_link_parsed = SignupDeepLink::from_str(&deep_link_str).unwrap(); + assert_eq!(deep_link_parsed.homeserver(), &homeserver); + assert_eq!(deep_link_parsed.relay(), None); + assert_eq!(deep_link_parsed.secret(), None); + } } diff --git a/pubky-sdk/src/actors/auth/http_relay_link_channel.rs b/pubky-sdk/src/actors/auth/http_relay_link_channel.rs index aa13fcc24..da0ac4479 100644 --- a/pubky-sdk/src/actors/auth/http_relay_link_channel.rs +++ b/pubky-sdk/src/actors/auth/http_relay_link_channel.rs @@ -120,7 +120,7 @@ impl HttpRelayLinkChannel { { return Ok(None); } - let poll_timeout = timeout.map(|t| t - start.elapsed()); + let poll_timeout = timeout.and_then(|t| t.checked_sub(start.elapsed())); match self.poll_once(client, poll_timeout).await { Ok(response) => { cross_log!( diff --git a/pubky-sdk/src/actors/signer/auth.rs b/pubky-sdk/src/actors/signer/auth.rs index b740951e0..7b384bd18 100644 --- a/pubky-sdk/src/actors/signer/auth.rs +++ b/pubky-sdk/src/actors/signer/auth.rs @@ -1,5 +1,6 @@ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use reqwest::Method; +use std::str::FromStr; use url::Url; use pubky_common::{ @@ -8,7 +9,8 @@ use pubky_common::{ }; use crate::{ - Capabilities, cross_log, + cross_log, + deep_links::DeepLink, errors::{AuthError, Result}, util::check_http_status, }; @@ -31,36 +33,88 @@ impl PubkySigner { /// - Returns [`crate::errors::Error::Authentication`] if the `pubkyauth://` URL is malformed or missing required parameters. /// - Returns [`crate::errors::Error::Authentication`] if the secret cannot be decoded or has the wrong length. /// - Propagates transport failures when posting to the relay or if the relay responds with a non-success status. - #[allow( - clippy::cognitive_complexity, - reason = "Approving a flow requires a fixed sequence of validation steps kept together for clarity" - )] pub async fn approve_auth(&self, pubkyauth_url: impl AsRef) -> Result<()> { - let pubkyauth_url = Url::parse(pubkyauth_url.as_ref())?; + let deep_link = DeepLink::from_str(pubkyauth_url.as_ref()) + .map_err(|e| AuthError::Validation(format!("Invalid pubkyauth URL: {e}")))?; + + match deep_link { + DeepLink::Signup(signup) => { + if signup.is_direct_signup() { + cross_log!( + info, + "Approving direct signup for homeserver {}", + signup.homeserver() + ); + self.signup(signup.homeserver(), signup.signup_token().as_deref()) + .await?; + return Ok(()); + } + + let relay = signup.relay().ok_or_else(|| { + AuthError::Validation("Missing 'relay' query parameter".to_string()) + })?; + let client_secret = signup.secret().ok_or_else(|| { + AuthError::Validation("Missing 'secret' query parameter".to_string()) + })?; + self.post_auth_token(relay, client_secret, signup.capabilities()) + .await?; + } + DeepLink::Signin(signin) => { + self.post_auth_token(signin.relay(), signin.secret(), signin.capabilities()) + .await?; + } + DeepLink::SeedExport(_) => { + return Err(AuthError::Validation( + "Seed export deep link is not an auth approval request".to_string(), + ) + .into()); + } + } + Ok(()) + } - // 1) Extract query params and decode client secret - let (relay, client_secret) = Self::parse_relay_and_secret(&pubkyauth_url)?; - cross_log!(info, "Approving auth flow via relay {relay}"); + fn build_encrypted_token( + &self, + capabilities: crate::Capabilities, + client_secret: &[u8; 32], + ) -> Vec { + let token = AuthToken::sign(&self.keypair, capabilities); + encrypt(&token.serialize(), client_secret) + } + + fn derive_callback_url(relay: &Url, client_secret: &[u8; 32]) -> Result { + let mut callback_url = relay.clone(); + let mut path_segments = callback_url + .path_segments_mut() + .map_err(|()| url::ParseError::RelativeUrlWithCannotBeABaseBase)?; + path_segments.pop_if_empty(); + let channel_id = URL_SAFE_NO_PAD.encode(hash(client_secret).as_bytes()); + path_segments.push(&channel_id); + drop(path_segments); + Ok(callback_url) + } - // 2) Build token with requested capabilities parsed from URL - let capabilities = Capabilities::from(&pubkyauth_url); + async fn post_auth_token( + &self, + relay: &Url, + client_secret: &[u8; 32], + capabilities: &crate::Capabilities, + ) -> Result<()> { + cross_log!(info, "Approving auth flow via relay {relay}"); cross_log!( info, "Signing capabilities {:?} for auth approval", capabilities ); - let encrypted_token = self.build_encrypted_token(capabilities, &client_secret); - - // 3) Derive channel: relay/ - let callback_url = Self::derive_callback_url(&relay, &client_secret)?; + let encrypted_token = self.build_encrypted_token(capabilities.clone(), client_secret); + let callback_url = Self::derive_callback_url(relay, client_secret)?; cross_log!( info, "Posting encrypted auth token to relay channel {}", callback_url ); - // 4) POST encrypted token let response = self .client .cross_request(Method::POST, callback_url) @@ -73,54 +127,4 @@ impl PubkySigner { cross_log!(info, "Auth token delivered successfully"); Ok(()) } - - fn parse_relay_and_secret(pubkyauth_url: &Url) -> Result<(Url, [u8; 32])> { - let mut relay_param: Option = None; - let mut secret_param: Option = None; - - for (key, value) in pubkyauth_url.query_pairs() { - match key.as_ref() { - "relay" if relay_param.is_none() => relay_param = Some(value.into_owned()), - "secret" if secret_param.is_none() => secret_param = Some(value.into_owned()), - _ => {} - } - } - - let relay_str = relay_param - .ok_or_else(|| AuthError::Validation("Missing 'relay' query parameter".to_string()))?; - let relay = Url::parse(&relay_str)?; - - let secret_str = secret_param - .ok_or_else(|| AuthError::Validation("Missing 'secret' query parameter".to_string()))?; - let secret_bytes = URL_SAFE_NO_PAD - .decode(secret_str) - .map_err(|e| AuthError::Validation(format!("Invalid base64 secret: {e}")))?; - - let client_secret: [u8; 32] = secret_bytes - .try_into() - .map_err(|_err| AuthError::Validation("Client secret must be 32 bytes".to_string()))?; - - Ok((relay, client_secret)) - } - - fn build_encrypted_token( - &self, - capabilities: Capabilities, - client_secret: &[u8; 32], - ) -> Vec { - let token = AuthToken::sign(&self.keypair, capabilities); - encrypt(&token.serialize(), client_secret) - } - - fn derive_callback_url(relay: &Url, client_secret: &[u8; 32]) -> Result { - let mut callback_url = relay.clone(); - let mut path_segments = callback_url - .path_segments_mut() - .map_err(|()| url::ParseError::RelativeUrlWithCannotBeABaseBase)?; - path_segments.pop_if_empty(); - let channel_id = URL_SAFE_NO_PAD.encode(hash(client_secret).as_bytes()); - path_segments.push(&channel_id); - drop(path_segments); - Ok(callback_url) - } }