use std::{fmt, str::FromStr};
use pubky_common::{
auth::jws::ClientId,
crypto::{Keypair, PublicKey},
};
use url::Url;
use crate::actors::Pkdns;
use crate::actors::auth::deep_links::DeepLink;
use crate::actors::auth::grant::approval::GrantApproval;
use crate::actors::auth::grant::builder::GrantAuthFlowBuilder;
use crate::actors::auth::grant::credential::GrantCredential;
use crate::actors::auth::grant::grant_exchange::credential_from_grant_exchange;
use crate::actors::auth::grant::pop_signer::{DelegatedSignFn, GrantPopSigner};
use crate::actors::auth::kind::AuthFlowKind;
use crate::actors::auth::relay::auth_relay_listener::AuthRelayListener;
use crate::errors::{AuthError, Result};
use crate::{Capabilities, PubkyHttpClient, PubkySession};
#[derive(Clone, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
pub struct GrantAuthFlowState {
pub authorization_url: String,
pub client_key_secret: [u8; 32],
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
pub struct DelegatedGrantAuthFlowState {
pub authorization_url: String,
pub key_id: String,
pub client_pk: PublicKey,
}
impl fmt::Debug for GrantAuthFlowState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GrantAuthFlowState")
.field("authorization_url", &self.authorization_url)
.field("client_key_secret", &"<redacted>")
.finish()
}
}
pub struct PubkyGrantAuthFlow {
relay_listener: AuthRelayListener,
client: PubkyHttpClient,
auth_url: Url,
client_signer: GrantPopSigner,
}
impl fmt::Debug for PubkyGrantAuthFlow {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PubkyGrantAuthFlow")
.field("relay_listener", &self.relay_listener)
.field("client", &self.client)
.field("auth_url", &self.auth_url)
.field("client_signer", &self.client_signer)
.finish()
}
}
impl PubkyGrantAuthFlow {
pub(crate) fn new(
relay_listener: AuthRelayListener,
client: PubkyHttpClient,
auth_url: Url,
client_signer: GrantPopSigner,
) -> Self {
Self {
relay_listener,
client,
auth_url,
client_signer,
}
}
pub fn start(
caps: &Capabilities,
auth_kind: AuthFlowKind,
client_id: ClientId,
) -> Result<Self> {
GrantAuthFlowBuilder::new(caps.clone(), auth_kind, client_id).start()
}
#[must_use]
pub fn builder(
caps: &Capabilities,
auth_kind: AuthFlowKind,
client_id: ClientId,
) -> GrantAuthFlowBuilder {
GrantAuthFlowBuilder::new(caps.clone(), auth_kind, client_id)
}
#[must_use]
pub fn authorization_url(&self) -> Url {
self.auth_url.clone()
}
#[must_use]
pub fn save_local(&self) -> Option<GrantAuthFlowState> {
Some(GrantAuthFlowState {
authorization_url: self.authorization_url().to_string(),
client_key_secret: self.client_signer.local_secret()?,
})
}
#[must_use]
pub fn save_delegated(&self) -> Option<DelegatedGrantAuthFlowState> {
let signer = self.client_signer.delegated_state()?;
Some(DelegatedGrantAuthFlowState {
authorization_url: self.authorization_url().to_string(),
key_id: signer.key_id,
client_pk: signer.public_key,
})
}
pub fn restore(state: GrantAuthFlowState, client: PubkyHttpClient) -> Result<Self> {
let GrantAuthFlowState {
authorization_url,
client_key_secret,
} = state;
let auth_url = DeepLink::from_str(&authorization_url).map_err(|e| {
AuthError::Validation(format!("failed to parse grant auth flow state URL: {e}"))
})?;
let (relay, secret, client_pk) = grant_deep_link_parts(&auth_url)?;
let client_keypair = Keypair::from_secret(&client_key_secret);
if &client_keypair.public_key() != client_pk {
return Err(AuthError::Validation(
"saved grant auth flow client key does not match the deep link client public key"
.into(),
)
.into());
}
let relay_listener = AuthRelayListener::builder(*secret)
.relay_base_url(relay.clone())
.client(client.clone())
.start()?;
Ok(Self::new(
relay_listener,
client,
auth_url.into(),
GrantPopSigner::local(client_keypair),
))
}
#[doc(hidden)]
pub fn restore_delegated(
state: DelegatedGrantAuthFlowState,
client: PubkyHttpClient,
sign: DelegatedSignFn,
) -> Result<Self> {
let DelegatedGrantAuthFlowState {
authorization_url,
key_id,
client_pk,
} = state;
let auth_url = DeepLink::from_str(&authorization_url).map_err(|e| {
AuthError::Validation(format!("failed to parse grant auth flow state URL: {e}"))
})?;
let (relay, secret, expected_client_pk) = grant_deep_link_parts(&auth_url)?;
if &client_pk != expected_client_pk {
return Err(AuthError::Validation(
"saved delegated grant auth flow client key does not match the deep link client public key"
.into(),
)
.into());
}
let relay_listener = AuthRelayListener::builder(*secret)
.relay_base_url(relay.clone())
.client(client.clone())
.start()?;
Ok(Self::new(
relay_listener,
client,
auth_url.into(),
GrantPopSigner::delegated(key_id, client_pk, sign),
))
}
pub async fn await_approval(self) -> Result<PubkySession> {
let client = self.client.clone();
let credential = self.await_credential().await?;
Ok(PubkySession::from_grant_credential(client, credential))
}
pub async fn await_credential(self) -> Result<GrantCredential> {
let Self {
relay_listener,
client,
client_signer,
..
} = self;
let approval = Self::await_decoded_approval(relay_listener).await?;
Self::exchange_for_credential(&client, approval, client_signer).await
}
pub async fn try_poll_once(&self) -> Result<Option<PubkySession>> {
let Some(credential) = self.try_poll_credential_once().await? else {
return Ok(None);
};
Ok(Some(PubkySession::from_grant_credential(
self.client.clone(),
credential,
)))
}
pub async fn try_poll_credential_once(&self) -> Result<Option<GrantCredential>> {
let Some(approval) = self.try_decoded_approval()? else {
return Ok(None);
};
let credential =
Self::exchange_for_credential(&self.client, approval, self.client_signer.clone())
.await?;
Ok(Some(credential))
}
async fn exchange_for_credential(
client: &PubkyHttpClient,
approval: GrantApproval,
client_signer: GrantPopSigner,
) -> Result<GrantCredential> {
let GrantApproval { jws, claims } = approval;
let pkdns = Pkdns::with_client(client.clone());
let hs_pk = pkdns.require_homeserver_of(&claims.iss).await?;
credential_from_grant_exchange(client, jws, claims, client_signer, hs_pk).await
}
async fn await_decoded_approval(relay_listener: AuthRelayListener) -> Result<GrantApproval> {
let message = relay_listener.await_message().await?;
GrantApproval::decode(&message)
}
fn try_decoded_approval(&self) -> Result<Option<GrantApproval>> {
let Some(message) = self.relay_listener.try_message() else {
return Ok(None);
};
Ok(Some(GrantApproval::decode(&message?)?))
}
}
fn grant_deep_link_parts(deep_link: &DeepLink) -> Result<(&Url, &[u8; 32], &PublicKey)> {
match deep_link {
DeepLink::SigninGrant(link) => Ok((
&link.params().relay,
&link.params().secret,
&link.params().client_pk,
)),
DeepLink::SignupGrant(link) => Ok((
&link.params().relay,
&link.params().secret,
&link.params().client_pk,
)),
_ => Err(AuthError::Validation(
"saved grant auth flow state must contain a grant signin or signup deep link".into(),
)
.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actors::auth::deep_links::{
DeepLinkScheme, SigninDeepLink, SigninGrantDeepLink, SigninGrantParams, SigninParams,
XCallbackParams,
};
#[tokio::test]
async fn save_restore_round_trips_authorization_url() {
let relay = http_relay::HttpRelay::builder()
.http_port(0)
.run()
.await
.unwrap();
let relay_url = relay.local_url().join("inbox").unwrap();
let client = PubkyHttpClient::new().unwrap();
let client_id = ClientId::new("save-restore.test").unwrap();
let x_callback = XCallbackParams {
x_success: Some("bitkit://auth/success?nonce=resume-grant".into()),
..XCallbackParams::default()
};
let flow = PubkyGrantAuthFlow::builder(
&Capabilities::default(),
AuthFlowKind::signin(),
client_id,
)
.relay(relay_url)
.client(client.clone())
.x_callback(x_callback.clone())
.start()
.unwrap();
let restored = PubkyGrantAuthFlow::restore(flow.save_local().unwrap(), client).unwrap();
assert_eq!(restored.authorization_url(), flow.authorization_url());
assert_eq!(
DeepLink::from_str(restored.authorization_url().as_str())
.unwrap()
.x_callback(),
&x_callback
);
}
#[tokio::test]
async fn save_local_is_only_available_for_local_signers() {
let relay = http_relay::HttpRelay::builder()
.http_port(0)
.run()
.await
.unwrap();
let relay_url = relay.local_url().join("inbox").unwrap();
let keypair = Keypair::random();
let delegated_signer = std::sync::Arc::new(|_| Box::pin(async { Ok(vec![0_u8; 64]) }) as _);
let client_id = ClientId::new("save-local.test").unwrap();
let local_flow = PubkyGrantAuthFlow::builder(
&Capabilities::default(),
AuthFlowKind::signin(),
client_id.clone(),
)
.relay(relay_url.clone())
.client_keypair(keypair.clone())
.start()
.unwrap();
let delegated_flow = PubkyGrantAuthFlow::builder(
&Capabilities::default(),
AuthFlowKind::signin(),
client_id,
)
.relay(relay_url)
.delegated_client_signer("key-1".into(), keypair.public_key(), delegated_signer)
.start()
.unwrap();
assert_eq!(
local_flow.save_local().unwrap().client_key_secret,
keypair.secret()
);
assert!(delegated_flow.save_local().is_none());
assert!(delegated_flow.save_delegated().is_some());
}
#[tokio::test]
async fn signup_builder_attaches_x_callback_metadata() {
let relay = http_relay::HttpRelay::builder()
.http_port(0)
.run()
.await
.unwrap();
let x_callback = XCallbackParams {
x_success: Some("bitkit://signup/success?nonce=grant-signup".into()),
..XCallbackParams::default()
};
let flow = PubkyGrantAuthFlow::builder(
&Capabilities::default(),
AuthFlowKind::signup(Keypair::random().public_key(), Some("signup-token".into())),
ClientId::new("grant-signup-callback.test").unwrap(),
)
.relay(relay.local_url().join("inbox").unwrap())
.x_callback(x_callback.clone())
.start()
.unwrap();
let deep_link = DeepLink::from_str(flow.authorization_url().as_str()).unwrap();
assert!(matches!(&deep_link, DeepLink::SignupGrant(_)));
assert_eq!(deep_link.x_callback(), &x_callback);
}
#[tokio::test]
async fn delegated_save_restore_preserves_x_callback_metadata() {
let relay = http_relay::HttpRelay::builder()
.http_port(0)
.run()
.await
.unwrap();
let client = PubkyHttpClient::new().unwrap();
let keypair = Keypair::random();
let sign: DelegatedSignFn =
std::sync::Arc::new(|_| Box::pin(async { Ok(vec![0_u8; 64]) }) as _);
let x_callback = XCallbackParams {
x_success: Some("bitkit://auth/success?nonce=delegated".into()),
..XCallbackParams::default()
};
let flow = PubkyGrantAuthFlow::builder(
&Capabilities::default(),
AuthFlowKind::signin(),
ClientId::new("delegated-callback.test").unwrap(),
)
.relay(relay.local_url().join("inbox").unwrap())
.delegated_client_signer(
"key-1".into(),
keypair.public_key(),
std::sync::Arc::clone(&sign),
)
.x_callback(x_callback.clone())
.start()
.unwrap();
let restored =
PubkyGrantAuthFlow::restore_delegated(flow.save_delegated().unwrap(), client, sign)
.unwrap();
assert_eq!(restored.authorization_url(), flow.authorization_url());
assert_eq!(
DeepLink::from_str(restored.authorization_url().as_str())
.unwrap()
.x_callback(),
&x_callback
);
}
#[test]
fn restore_rejects_cookie_auth_url() {
let auth_url = SigninDeepLink::new(
DeepLinkScheme::PubkyAuth,
SigninParams {
capabilities: Capabilities::default(),
relay: Url::parse("http://localhost/inbox").unwrap(),
secret: [7; 32],
},
)
.to_string();
let state = GrantAuthFlowState {
authorization_url: auth_url,
client_key_secret: Keypair::random().secret(),
};
let error = PubkyGrantAuthFlow::restore(state, PubkyHttpClient::new().unwrap())
.unwrap_err()
.to_string();
assert!(error.contains("grant signin or signup deep link"));
}
#[test]
fn restore_rejects_mismatched_client_key() {
let expected_client = Keypair::random();
let actual_client = Keypair::random();
let auth_url = SigninGrantDeepLink::new(
DeepLinkScheme::PubkyAuth,
SigninGrantParams {
capabilities: Capabilities::default(),
relay: Url::parse("http://localhost/inbox").unwrap(),
secret: [7; 32],
client_id: ClientId::new("mismatch.test").unwrap(),
client_pk: expected_client.public_key(),
},
)
.to_string();
let state = GrantAuthFlowState {
authorization_url: auth_url,
client_key_secret: actual_client.secret(),
};
let error = PubkyGrantAuthFlow::restore(state, PubkyHttpClient::new().unwrap())
.unwrap_err()
.to_string();
assert!(error.contains("does not match"));
}
#[cfg(feature = "json")]
#[test]
fn state_serializes_round_trip() {
let state = GrantAuthFlowState {
authorization_url: "pubkyauth://signin?caps=&relay=http://localhost/inbox".into(),
client_key_secret: [42; 32],
};
let json = serde_json::to_string(&state).unwrap();
let restored: GrantAuthFlowState = serde_json::from_str(&json).unwrap();
assert_eq!(restored, state);
}
}