use url::Url;
use pubky_common::crypto::random_bytes;
use crate::{
AuthToken, Capabilities, PubkyHttpClient, PubkySession, PublicKey,
actors::{
DEFAULT_HTTP_RELAY_INBOX,
auth::{
auth_subscription::AuthSubscription,
deep_links::{DeepLink, SigninDeepLink, SignupDeepLink},
},
},
errors::Result,
};
#[derive(Debug)]
pub struct PubkyAuthFlow {
subscription: AuthSubscription,
auth_url: DeepLink,
}
impl PubkyAuthFlow {
pub fn start(caps: &Capabilities, auth_kind: AuthFlowKind) -> Result<Self> {
PubkyAuthFlowBuilder::new(caps.clone(), auth_kind).start()
}
#[must_use]
pub fn builder(caps: &Capabilities, auth_kind: AuthFlowKind) -> PubkyAuthFlowBuilder {
PubkyAuthFlowBuilder::new(caps.clone(), auth_kind)
}
#[must_use]
pub fn authorization_url(&self) -> Url {
self.auth_url.clone().into()
}
pub async fn await_approval(self) -> Result<PubkySession> {
self.subscription.await_approval().await
}
pub async fn await_token(self) -> Result<AuthToken> {
self.subscription.await_token().await
}
pub async fn try_poll_once(&self) -> Result<Option<PubkySession>> {
self.subscription.try_poll_once().await
}
#[must_use]
pub fn try_token(&self) -> Option<Result<AuthToken>> {
self.subscription.try_token()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthFlowKind {
SignIn,
SignUp {
homeserver_public_key: Box<PublicKey>,
signup_token: Option<String>,
},
}
impl AuthFlowKind {
#[must_use]
pub fn signin() -> Self {
Self::SignIn
}
#[must_use]
pub fn signup(homeserver_public_key: PublicKey, signup_token: Option<String>) -> Self {
Self::SignUp {
homeserver_public_key: Box::new(homeserver_public_key),
signup_token,
}
}
}
#[derive(Debug, Clone)]
pub struct PubkyAuthFlowBuilder {
caps: Capabilities,
base_relay: Url,
client: Option<PubkyHttpClient>,
auth_kind: AuthFlowKind,
client_secret: [u8; 32],
}
impl PubkyAuthFlowBuilder {
pub(crate) fn new(caps: Capabilities, auth_kind: AuthFlowKind) -> Self {
Self {
caps,
base_relay: Url::parse(DEFAULT_HTTP_RELAY_INBOX)
.expect("Should be able to parse the default HTTP relay"),
client: None,
auth_kind,
client_secret: random_bytes::<32>(),
}
}
pub fn relay(mut self, url: Url) -> Self {
self.base_relay = url;
self
}
pub fn client(mut self, client: PubkyHttpClient) -> Self {
self.client = Some(client);
self
}
pub fn client_secret(mut self, client_secret: [u8; 32]) -> Self {
self.client_secret = client_secret;
self
}
pub fn start(self) -> Result<PubkyAuthFlow> {
let client = match &self.client {
Some(c) => c.clone(),
None => PubkyHttpClient::new()?,
};
let auth_url = self.create_url();
let subscription = AuthSubscription::builder(self.client_secret)
.relay_base_url(self.base_relay)
.client(client)
.start()?;
Ok(PubkyAuthFlow {
subscription,
auth_url,
})
}
fn create_url(&self) -> DeepLink {
match &self.auth_kind {
AuthFlowKind::SignIn => DeepLink::Signin(SigninDeepLink::new(
self.caps.clone(),
self.base_relay.clone(),
self.client_secret,
)),
AuthFlowKind::SignUp {
homeserver_public_key,
signup_token,
} => DeepLink::Signup(SignupDeepLink::new(
self.caps.clone(),
self.base_relay.clone(),
self.client_secret,
*homeserver_public_key.clone(),
signup_token.clone(),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Keypair, Pubky};
use std::str::FromStr;
async fn assert_resume_reconnects(auth_kind: AuthFlowKind) {
let relay = http_relay::HttpRelay::builder()
.http_port(0)
.run()
.await
.unwrap();
let inbox_base = relay.local_url().join("inbox").unwrap();
let client = PubkyHttpClient::new().unwrap();
let pubky = Pubky::with_client(client.clone());
let caps = Capabilities::default();
let flow = PubkyAuthFlow::builder(&caps, auth_kind)
.client(client.clone())
.relay(inbox_base)
.start()
.unwrap();
let auth_url_str = flow.authorization_url().as_str().to_string();
let deep_link = DeepLink::from_str(&auth_url_str).unwrap();
let secret = match &deep_link {
DeepLink::Signin(s) => *s.secret(),
DeepLink::Signup(s) => *s.secret(),
_ => panic!("Expected signin or signup deep link"),
};
drop(flow);
let keypair = Keypair::random();
let token = AuthToken::sign(&keypair, caps);
let token_bytes = token.serialize();
let encrypted_channel =
crate::actors::auth::http_relay_inbox_channel::EncryptedHttpRelayInboxChannel::new(
relay.local_url().join("inbox").unwrap(),
secret,
)
.unwrap();
encrypted_channel
.produce(&client, &token_bytes)
.await
.unwrap();
let resumed = pubky.resume_auth_flow(&auth_url_str).unwrap();
assert_eq!(
resumed.authorization_url().as_str(),
auth_url_str,
"resumed flow produces the same authorization URL"
);
let received_token = resumed.await_token().await.unwrap();
assert_eq!(
received_token, token,
"resumed flow retrieves the original token"
);
}
#[tokio::test]
async fn resume_signin_reconnects_to_same_channel() {
assert_resume_reconnects(AuthFlowKind::signin()).await;
}
#[tokio::test]
async fn resume_signup_reconnects_to_same_channel() {
let homeserver = Keypair::random().public_key();
let signup_token = Some("test-signup-token".to_string());
assert_resume_reconnects(AuthFlowKind::signup(homeserver, signup_token)).await;
}
#[test]
fn resume_rejects_invalid_url() {
let client = PubkyHttpClient::new().unwrap();
let pubky = Pubky::with_client(client);
let result = pubky.resume_auth_flow("https://not-a-pubkyauth-url.com");
assert!(result.is_err(), "non-pubkyauth URL should fail to resume");
}
#[test]
fn resume_rejects_seed_export_url() {
let client = PubkyHttpClient::new().unwrap();
let pubky = Pubky::with_client(client);
let url = "pubkyauth://secret_export?secret=kqnceEMgrNQM_xi06oQXjA3cJHX_RQmw1BY6JE1bse8";
let result = pubky.resume_auth_flow(url);
assert!(result.is_err(), "seed export URL should fail to resume");
}
}