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::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],
}
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_keypair: Keypair,
}
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_keypair", &"<redacted>")
.finish()
}
}
impl PubkyGrantAuthFlow {
pub(crate) fn new(
relay_listener: AuthRelayListener,
client: PubkyHttpClient,
auth_url: Url,
client_keypair: Keypair,
) -> Self {
Self {
relay_listener,
client,
auth_url,
client_keypair,
}
}
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(&self) -> GrantAuthFlowState {
GrantAuthFlowState {
authorization_url: self.authorization_url().to_string(),
client_key_secret: self.client_keypair.secret(),
}
}
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(),
client_keypair,
))
}
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_keypair,
..
} = self;
let approval = Self::await_decoded_approval(relay_listener).await?;
Self::exchange_for_credential(&client, approval, client_keypair).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_keypair.clone())
.await?;
Ok(Some(credential))
}
async fn exchange_for_credential(
client: &PubkyHttpClient,
approval: GrantApproval,
client_keypair: Keypair,
) -> Result<GrantCredential> {
let GrantApproval { jws, claims } = approval;
let pkdns = Pkdns::with_client(client.clone());
let hs_pk = pkdns.get_homeserver_of(&claims.iss).await.ok_or_else(|| {
AuthError::Validation(format!(
"could not resolve homeserver for {}",
claims.iss.z32()
))
})?;
credential_from_grant_exchange(client, jws, claims, client_keypair, 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,
};
#[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 flow = PubkyGrantAuthFlow::builder(
&Capabilities::default(),
AuthFlowKind::signin(),
client_id,
)
.relay(relay_url)
.client(client.clone())
.start()
.unwrap();
let restored = PubkyGrantAuthFlow::restore(flow.save(), client).unwrap();
assert_eq!(restored.authorization_url(), flow.authorization_url());
}
#[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);
}
}