use std::any::Any;
use std::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use pubky_common::{
auth::{
grant::GrantClaims,
grant_session_responses::{GrantSessionInfo, GrantSessionResponse},
jws::{POP_JWS_TYP, PopNonce},
pop::PopProofClaims,
},
crypto::{Keypair, PublicKey},
encryption_keys::ScopedEncryptionKeyBundle,
};
use reqwest::{Method, RequestBuilder, StatusCode};
use tokio::sync::Mutex;
use zeroize::Zeroizing;
#[cfg(target_arch = "wasm32")]
use super::shared_session::GrantSessionLease;
use super::{
approval::{GrantApproval, SignedApproval, VerifiedApproval},
grant_exchange::{credential_from_grant_exchange, refresh_grant_session},
pop_signer::{DelegatedSignFn, GrantPopSigner},
shared_session::GrantSessionCoordinator,
};
use crate::actors::session::core::PubkySession;
use crate::actors::session::credential::{SessionCredential, credential_session_missing};
use crate::{
PubkyHttpClient,
actors::session::SessionInfo,
cross_log,
errors::{AuthError, CredentialRefreshError, Error, RequestError, Result},
};
pub(crate) const REFRESH_SLACK_SECS: u64 = 300;
const GRANT_SESSION_PATH: &str = "/auth/grant/session";
const STORED_GRANT_CREDENTIAL_PREFIX: &str = "pubky-grant-credential-v1";
const STORED_GRANT_CREDENTIAL_APPROVAL_PREFIX: &str = "pubky-grant-credential-v2";
const STORED_GRANT_CREDENTIAL_PREFIX_FAMILY: &str = "pubky-grant-credential-";
pub(crate) fn now_unix() -> u64 {
web_time::SystemTime::now()
.duration_since(web_time::UNIX_EPOCH)
.map(|d| d.as_secs())
.expect("System time duration_since should always valid")
}
#[derive(Debug)]
pub(crate) struct GrantCredentialState {
pub bearer: String,
pub grant_jws: String,
pub grant_claims: GrantClaims,
pub client_signer: GrantPopSigner,
pub homeserver_pk: PublicKey,
pub session: GrantSessionInfo,
pub coordinator: Option<Arc<dyn GrantSessionCoordinator>>,
}
impl GrantCredentialState {
pub(super) fn needs_refresh(&self, now: u64, slack: u64) -> bool {
self.session.token_expires_at <= now
|| (self.session.token_expires_at < self.grant_claims.exp
&& self.session.token_expires_at.saturating_sub(slack) <= now)
}
}
#[derive(Clone, Debug)]
pub struct GrantCredential {
pub(crate) state: Arc<Mutex<GrantCredentialState>>,
pub(crate) info: SessionInfo,
verified_approval: Option<Arc<VerifiedApproval>>,
}
#[derive(Debug)]
struct GrantRestoreMaterial {
grant_jws: String,
grant_claims: GrantClaims,
client_signer: GrantPopSigner,
homeserver_pk: PublicKey,
verified_approval: Option<VerifiedApproval>,
}
#[derive(Clone)]
struct StoredGrantCredential {
grant_jws: String,
client_key_secret: [u8; 32],
homeserver_pk: PublicKey,
signed_approval: Option<SignedApproval>,
}
#[derive(Clone, PartialEq, Eq)]
pub struct DelegatedGrantCredentialState {
pub grant_jws: String,
pub homeserver_pk: PublicKey,
pub key_id: String,
pub client_pk: PublicKey,
}
impl fmt::Debug for DelegatedGrantCredentialState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DelegatedGrantCredentialState")
.field("grant_jws", &"<redacted>")
.field("homeserver_pk", &self.homeserver_pk)
.field("key_id", &self.key_id)
.field("client_pk", &self.client_pk)
.finish()
}
}
impl fmt::Debug for StoredGrantCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StoredGrantCredential")
.field("grant_jws", &"<redacted>")
.field("client_key_secret", &"<redacted>")
.field("homeserver_pk", &self.homeserver_pk)
.field("signed_approval", &self.signed_approval)
.finish()
}
}
impl StoredGrantCredential {
fn encode(&self) -> String {
let secret = Zeroizing::new(URL_SAFE_NO_PAD.encode(self.client_key_secret));
match &self.signed_approval {
Some(approval) => format!(
"{STORED_GRANT_CREDENTIAL_APPROVAL_PREFIX}:{}:{}:{}:{}",
self.homeserver_pk.z32(),
secret.as_str(),
self.grant_jws,
approval.as_str(),
),
None => format!(
"{STORED_GRANT_CREDENTIAL_PREFIX}:{}:{}:{}",
self.homeserver_pk.z32(),
secret.as_str(),
self.grant_jws,
),
}
}
fn decode(token: &str) -> Result<Self> {
let (prefix, rest) = token.split_once(':').ok_or_else(invalid_stored_grant)?;
if prefix != STORED_GRANT_CREDENTIAL_PREFIX
&& prefix != STORED_GRANT_CREDENTIAL_APPROVAL_PREFIX
{
return Err(RequestError::Validation {
message: "unsupported grant credential token version".into(),
}
.into());
}
let (homeserver, rest) = rest.split_once(':').ok_or_else(invalid_stored_grant)?;
let (secret, grant_jws) = rest.split_once(':').ok_or_else(invalid_stored_grant)?;
let (grant_jws, signed_approval) = if prefix == STORED_GRANT_CREDENTIAL_APPROVAL_PREFIX {
let (grant, approval) = grant_jws.split_once(':').ok_or_else(invalid_stored_grant)?;
if approval.is_empty() {
return Err(invalid_stored_grant().into());
}
(grant, Some(SignedApproval::new(approval)))
} else {
(grant_jws, None)
};
if grant_jws.is_empty() {
return Err(invalid_stored_grant().into());
}
let homeserver_pk =
PublicKey::try_from_z32(homeserver).map_err(|_err| RequestError::Validation {
message: "invalid stored grant credential homeserver public key".into(),
})?;
let secret = Zeroizing::new(URL_SAFE_NO_PAD.decode(secret).map_err(|_err| {
RequestError::Validation {
message: "invalid stored grant credential client secret".into(),
}
})?);
let client_key_secret =
<[u8; 32]>::try_from(secret.as_slice()).map_err(|_err| RequestError::Validation {
message: "stored grant credential client secret must be 32 bytes".into(),
})?;
Ok(Self {
grant_jws: grant_jws.to_string(),
client_key_secret,
homeserver_pk,
signed_approval,
})
}
}
impl GrantCredential {
pub(crate) fn from_response(
response: GrantSessionResponse,
grant_jws: String,
grant_claims: GrantClaims,
client_signer: GrantPopSigner,
homeserver_pk: PublicKey,
) -> Self {
let info = to_session_info(&response.session);
let state = GrantCredentialState {
bearer: response.token,
grant_jws,
grant_claims,
client_signer,
homeserver_pk,
session: response.session,
coordinator: None,
};
Self {
state: Arc::new(Mutex::new(state)),
info,
verified_approval: None,
}
}
#[doc(hidden)]
pub fn from_shared_secret(token: &str) -> Result<Self> {
let material = restore_material(StoredGrantCredential::decode(token)?, true)?;
Ok(Self::from_shared_material(material))
}
#[doc(hidden)]
pub fn from_shared_delegated_state(
state: DelegatedGrantCredentialState,
sign: DelegatedSignFn,
signed_approval: Option<&str>,
) -> Result<Self> {
Ok(Self::from_shared_material(restore_delegated_material(
state,
sign,
true,
signed_approval,
)?))
}
fn from_shared_material(material: GrantRestoreMaterial) -> Self {
let GrantRestoreMaterial {
grant_jws,
grant_claims: claims,
client_signer: signer,
homeserver_pk: homeserver,
verified_approval,
} = material;
let response = GrantSessionResponse {
token: String::new(),
session: GrantSessionInfo {
homeserver: homeserver.clone(),
pubky: claims.iss.clone(),
client_id: claims.client_id.clone(),
capabilities: claims.caps.clone(),
grant_id: claims.jti.clone(),
token_expires_at: 0,
grant_expires_at: claims.exp,
created_at: 0,
},
};
let mut credential = Self::from_response(response, grant_jws, claims, signer, homeserver);
credential.retain_verified_approval(verified_approval);
credential
}
async fn exchange_restored_material(
material: GrantRestoreMaterial,
client: &PubkyHttpClient,
) -> Result<Self> {
let mut credential = credential_from_grant_exchange(
client,
material.grant_jws,
material.grant_claims,
material.client_signer,
material.homeserver_pk,
)
.await?;
credential.retain_verified_approval(material.verified_approval);
Ok(credential)
}
pub(crate) fn retain_verified_approval(&mut self, approval: Option<VerifiedApproval>) {
self.verified_approval = approval.map(Arc::new);
}
pub fn encryption_keys(&self) -> Option<&ScopedEncryptionKeyBundle> {
self.verified_approval
.as_ref()
.map(|approval| &approval.encryption_keys)
}
pub(crate) async fn current_bearer(&self) -> String {
self.state.lock().await.bearer.clone()
}
pub async fn export_local_secret(&self) -> Option<String> {
let state = self.state.lock().await;
let client_key_secret = state.client_signer.local_secret()?;
Some(
StoredGrantCredential {
grant_jws: state.grant_jws.clone(),
client_key_secret,
homeserver_pk: state.homeserver_pk.clone(),
signed_approval: self
.verified_approval
.as_ref()
.map(|approval| approval.signed_approval.clone()),
}
.encode(),
)
}
pub fn signed_approval(&self) -> Option<&str> {
self.verified_approval
.as_ref()
.map(|approval| approval.signed_approval.as_str())
}
pub async fn export_delegated_restore_state(&self) -> Option<DelegatedGrantCredentialState> {
let state = self.state.lock().await;
let signer = state.client_signer.delegated_state()?;
Some(DelegatedGrantCredentialState {
grant_jws: state.grant_jws.clone(),
homeserver_pk: state.homeserver_pk.clone(),
key_id: signer.key_id,
client_pk: signer.public_key,
})
}
pub(crate) fn is_secret_token(token: &str) -> bool {
token.starts_with(STORED_GRANT_CREDENTIAL_PREFIX_FAMILY)
}
pub fn restore_encryption_keys(
token: &str,
) -> Result<Option<(GrantClaims, ScopedEncryptionKeyBundle)>> {
let saved = StoredGrantCredential::decode(token)?;
Self::restore_encryption_keys_from_approval(
&saved.grant_jws,
saved.signed_approval.as_ref().map(SignedApproval::as_str),
)
}
pub fn restore_encryption_keys_from_approval(
grant_jws: &str,
signed_approval: Option<&str>,
) -> Result<Option<(GrantClaims, ScopedEncryptionKeyBundle)>> {
let approval = restore_verified_approval(signed_approval, grant_jws)?;
Ok(approval.map(|(claims, verified)| (claims, verified.encryption_keys)))
}
pub async fn import_secret(token: &str, client: &PubkyHttpClient) -> Result<Self> {
let saved = StoredGrantCredential::decode(token)?;
Self::exchange_restored_material(restore_material(saved, false)?, client).await
}
pub async fn import_delegated_state(
state: DelegatedGrantCredentialState,
client: &PubkyHttpClient,
sign: DelegatedSignFn,
signed_approval: Option<&str>,
) -> Result<Self> {
let material = restore_delegated_material(state, sign, false, signed_approval)?;
Self::exchange_restored_material(material, client).await
}
pub(crate) async fn refresh(&self, client: &PubkyHttpClient) -> Result<()> {
if let Some(coordinator) = self.coordinator().await {
return self
.refresh_shared(client, coordinator.as_ref(), None)
.await;
}
cross_log!(info, "Refreshing grant credential");
let mut state = self.state.lock().await;
if !state.needs_refresh(now_unix(), REFRESH_SLACK_SECS / 2) {
return Ok(());
}
let parsed = refresh_grant_session(
client,
&state.grant_jws,
&state.grant_claims,
&state.client_signer,
&state.homeserver_pk,
)
.await?;
state.bearer = parsed.token;
state.session = parsed.session;
Ok(())
}
async fn grant_session_request(
&self,
client: &PubkyHttpClient,
method: Method,
) -> Result<RequestBuilder> {
let (homeserver, user) = {
let state = self.state.lock().await;
(state.homeserver_pk.clone(), state.grant_claims.iss.clone())
};
client
.cross_request_via_homeserver(method, &homeserver, &user, GRANT_SESSION_PATH)
.await
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl SessionCredential for GrantCredential {
fn info(&self) -> SessionInfo {
self.info.clone()
}
async fn signout(&self, client: &PubkyHttpClient) -> Result<()> {
let lease = match self.coordinator().await {
Some(coordinator) => Some(coordinator.acquire(true).await?),
None => None,
};
if let Some(lease) = &lease {
let Some(mut shared) = lease.load().await? else {
return Ok(());
};
self.state.lock().await.adopt(shared.response.clone())?;
shared.logout_pending = true;
lease.store(&shared).await?;
}
let request = self.grant_session_request(client, Method::DELETE).await?;
let homeserver = self.state.lock().await.homeserver_pk.clone();
let supports_proof_logout = client
.features
.supports(
client,
&homeserver,
pubky_common::constants::features::GRANT_PROOF_LOGOUT,
)
.await;
let proof = {
let state = self.state.lock().await;
if supports_proof_logout {
let pop = sign_pop_for_grant(
&state.client_signer,
&state.homeserver_pk,
&state.grant_claims.jti,
)
.await?;
Some(serde_json::json!({ "grant": state.grant_jws, "pop": pop }))
} else {
None
}
};
let request = match proof {
Some(proof) => request.json(&proof),
None => request.bearer_auth(self.current_bearer().await),
};
let response = request.send().await?;
client.check_http_status(response).await?;
if let Some(lease) = lease {
lease.remove().await?;
}
Ok(())
}
async fn send(
&self,
rb: RequestBuilder,
client: &PubkyHttpClient,
) -> Result<reqwest::Response> {
if let Some(coordinator) = self.coordinator().await {
return self.send_shared(rb, client, coordinator.as_ref()).await;
}
Ok(self.attach(rb, client).await?.send().await?)
}
async fn attach(&self, rb: RequestBuilder, client: &PubkyHttpClient) -> Result<RequestBuilder> {
let needs_refresh = {
let grant_state = self.state.lock().await;
grant_state.needs_refresh(now_unix(), REFRESH_SLACK_SECS)
};
if needs_refresh {
self.refresh(client).await?;
}
let bearer = self.state.lock().await.bearer.clone();
Ok(rb.bearer_auth(bearer))
}
#[cfg(target_arch = "wasm32")]
async fn prepare_external(
&self,
rb: RequestBuilder,
client: &PubkyHttpClient,
) -> Result<(RequestBuilder, Option<Box<dyn GrantSessionLease>>)> {
if let Some(coordinator) = self.coordinator().await {
let (request, lease, _) = self
.prepare_shared(rb, client, coordinator.as_ref())
.await?;
return Ok((request, Some(lease)));
}
Ok((self.attach(rb, client).await?, None))
}
#[cfg(target_arch = "wasm32")]
async fn refresh_rejected_bearer(
&self,
bearer: &str,
client: &PubkyHttpClient,
) -> Result<bool> {
let Some(coordinator) = self.coordinator().await else {
return Ok(false);
};
self.refresh_shared(client, coordinator.as_ref(), Some(bearer))
.await?;
Ok(true)
}
async fn can_attach_to(&self, homeserver: &PublicKey) -> bool {
&self.state.lock().await.homeserver_pk == homeserver
}
async fn revalidate(
&self,
client: &PubkyHttpClient,
_user: &PublicKey,
) -> Result<Option<SessionInfo>> {
let request = self.grant_session_request(client, Method::GET).await?;
let response = if let Some(coordinator) = self.coordinator().await {
match self
.send_shared(request, client, coordinator.as_ref())
.await
{
Err(Error::CredentialRefresh(CredentialRefreshError::Request {
status: Some(StatusCode::UNAUTHORIZED | StatusCode::NOT_FOUND),
})) => return Ok(None),
result => result?,
}
} else {
request
.bearer_auth(self.current_bearer().await)
.send()
.await?
};
if credential_session_missing(&response) {
return Ok(None);
}
let response = client.check_http_status(response).await?;
let session: GrantSessionInfo =
response
.json()
.await
.map_err(|e| RequestError::DecodeJson {
message: format!("decoding /auth/grant/session response: {e}"),
})?;
Ok(Some(to_session_info(&session)))
}
fn as_any(&self) -> &dyn Any {
self
}
}
impl PubkySession {
#[must_use]
pub fn from_grant_credential(client: PubkyHttpClient, credential: GrantCredential) -> Self {
Self::from_credential(client, Arc::new(credential))
}
pub async fn import_grant_secret(token: &str, client: Option<PubkyHttpClient>) -> Result<Self> {
let client = match client {
Some(client) => client,
None => PubkyHttpClient::new()?,
};
let credential = GrantCredential::import_secret(token, &client).await?;
Ok(Self::from_grant_credential(client, credential))
}
}
fn to_session_info(session: &GrantSessionInfo) -> SessionInfo {
SessionInfo::new(session.pubky.clone(), session.capabilities.clone())
}
fn restore_material(
saved: StoredGrantCredential,
allow_expired: bool,
) -> Result<GrantRestoreMaterial> {
let verified_approval = restore_verified_approval(
saved.signed_approval.as_ref().map(SignedApproval::as_str),
&saved.grant_jws,
)?
.map(|(_, approval)| approval);
let grant_claims = GrantClaims::decode(&saved.grant_jws).map_err(|err| {
AuthError::Validation(format!("invalid stored grant credential grant JWS: {err}"))
})?;
if !allow_expired && grant_claims.exp <= now_unix() {
return Err(AuthError::Validation("stored grant credential has expired".into()).into());
}
let client_keypair = Keypair::from_secret(&saved.client_key_secret);
if client_keypair.public_key() != grant_claims.cnf {
return Err(AuthError::Validation(
"stored grant credential client key does not match the grant cnf".into(),
)
.into());
}
Ok(GrantRestoreMaterial {
grant_jws: saved.grant_jws,
grant_claims,
client_signer: GrantPopSigner::local(client_keypair),
homeserver_pk: saved.homeserver_pk,
verified_approval,
})
}
fn restore_verified_approval(
signed: Option<&str>,
grant_jws: &str,
) -> Result<Option<(GrantClaims, VerifiedApproval)>> {
let Some(signed) = signed else {
return Ok(None);
};
let approval = GrantApproval::decode_text(
signed,
crate::actors::auth::deep_links::GrantApprovalFormat::SignedApprovalV1,
)?;
if approval.grant_jws != grant_jws {
return Err(AuthError::Validation(
"stored approval does not match the stored grant".into(),
)
.into());
}
Ok(approval
.verified_approval
.map(|verified| (approval.claims, verified)))
}
fn restore_delegated_material(
saved: DelegatedGrantCredentialState,
sign: DelegatedSignFn,
allow_expired: bool,
signed_approval: Option<&str>,
) -> Result<GrantRestoreMaterial> {
let verified_approval =
restore_verified_approval(signed_approval, &saved.grant_jws)?.map(|(_, approval)| approval);
let grant_claims = GrantClaims::decode(&saved.grant_jws).map_err(|err| {
AuthError::Validation(format!(
"invalid delegated grant credential grant JWS: {err}"
))
})?;
if !allow_expired && grant_claims.exp <= now_unix() {
return Err(AuthError::Validation("delegated grant credential has expired".into()).into());
}
if saved.client_pk != grant_claims.cnf {
return Err(AuthError::Validation(
"delegated grant credential client key does not match the grant cnf".into(),
)
.into());
}
Ok(GrantRestoreMaterial {
grant_jws: saved.grant_jws,
grant_claims,
client_signer: GrantPopSigner::delegated(saved.key_id, saved.client_pk, sign),
homeserver_pk: saved.homeserver_pk,
verified_approval,
})
}
fn invalid_stored_grant() -> AuthError {
AuthError::Validation(format!(
"invalid stored grant credential: expected `{STORED_GRANT_CREDENTIAL_PREFIX}:<homeserver>:<client_secret>:<grant_jws>`"
))
}
pub(crate) async fn sign_pop_for_grant(
client_signer: &GrantPopSigner,
homeserver_pk: &PublicKey,
grant_id: &pubky_common::auth::jws::GrantId,
) -> Result<String> {
let claims = PopProofClaims {
aud: homeserver_pk.clone(),
gid: grant_id.clone(),
nonce: PopNonce::generate(),
iat: now_unix(),
};
client_signer
.sign_jws(POP_JWS_TYP, &claims)
.await
.map_err(|error| AuthError::Validation(error.to_string()).into())
}
#[cfg(test)]
mod tests {
use std::{num::NonZeroUsize, time::Duration};
use pkarr::{Cache, InMemoryCache, SignedPacket, dns::rdata::SVCB};
use pubky_common::{
auth::jws::{ClientId, GRANT_JWS_TYP, GrantId},
capabilities::{Capabilities, Capability},
session::CookieSessionRecord,
};
use pubky_testnet::EphemeralTestnet;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
use tokio_rustls::TlsAcceptor;
use super::super::shared_session::{GrantSessionLease, SharedGrantSession};
use super::*;
#[allow(
deprecated,
reason = "cookie credential is the supported non-grant control"
)]
use crate::CookieCredential;
#[tokio::test]
#[pubky_testnet::test]
async fn capped_bearer_requests_and_legacy_logout_do_not_refresh() {
let testnet = EphemeralTestnet::builder().build().await.unwrap();
let user = Keypair::random();
let homeserver = testnet.homeserver_app().public_key();
testnet
.sdk()
.unwrap()
.signer(user.clone())
.signup(&homeserver, None)
.await
.unwrap();
let mut builder = PubkyHttpClient::builder();
builder.pkarr(|b| {
*b = testnet.pkarr_client_builder();
b
});
let client = builder.build().unwrap();
client.features.insert(&homeserver, &[]);
let pop_key = Keypair::random();
let now = now_unix();
let grant = GrantClaims {
iss: user.public_key(),
client_id: ClientId::new("refresh.test").unwrap(),
caps: vec![Capability::root()],
cnf: pop_key.public_key(),
jti: GrantId::generate(),
iat: now,
exp: now + 120,
};
let jws = grant.sign(&user, GRANT_JWS_TYP);
let signer = GrantPopSigner::local(pop_key);
let credential = credential_from_grant_exchange(&client, jws, grant, signer, homeserver)
.await
.unwrap();
let state = credential.state.lock().await;
let bearer = state.bearer.clone();
assert_eq!(state.session.token_expires_at, state.grant_claims.exp);
drop(state);
for _ in 0..3 {
credential.refresh(&client).await.unwrap();
let request = credential
.grant_session_request(&client, Method::GET)
.await
.unwrap();
let response = credential
.attach(request, &client)
.await
.unwrap()
.send()
.await
.unwrap();
client.check_http_status(response).await.unwrap();
assert_eq!(credential.current_bearer().await, bearer);
}
credential.state.lock().await.session.token_expires_at = now_unix() + 60;
credential.signout(&client).await.unwrap();
assert!(
credential
.revalidate(&client, &user.public_key())
.await
.unwrap()
.is_none()
);
}
#[test]
fn refresh_policy_preserves_capped_bearers_until_expiry() {
let (stored, claims) = stored_credential(5_000);
let signer = GrantPopSigner::local(Keypair::from_secret(&stored.client_key_secret));
let credential = test_credential(stored, claims, signer);
let mut state = credential.state.blocking_lock();
let now = 1_000;
for (bearer_exp, grant_exp, slack, expected) in [
(1_301, 5_000, 300, false),
(1_300, 5_000, 300, true),
(1_151, 5_000, 150, false),
(1_150, 5_000, 150, true),
(1_120, 1_120, 150, false),
(1_120, 1_100, 150, false),
(1_000, 1_000, 150, true),
(999, 999, 150, true),
(0, 1_120, 150, true),
] {
state.session.token_expires_at = bearer_exp;
state.grant_claims.exp = grant_exp;
assert_eq!(
state.needs_refresh(now, slack),
expected,
"bearer_exp={bearer_exp}, grant_exp={grant_exp}, slack={slack}"
);
}
}
#[test]
fn stored_grant_credential_encode_decode_round_trips() {
let (stored, _claims) = stored_credential(now_unix() + 3600);
let encoded = stored.encode();
let decoded = StoredGrantCredential::decode(&encoded).unwrap();
assert_eq!(decoded.grant_jws, stored.grant_jws);
assert_eq!(decoded.client_key_secret, stored.client_key_secret);
assert_eq!(decoded.homeserver_pk, stored.homeserver_pk);
assert!(decoded.signed_approval.is_none());
}
#[test]
fn key_bearing_secret_round_trip_verifies_approval_and_preserves_keys() {
let identity = Keypair::random();
let (mut stored, mut claims) = stored_credential(now_unix() + 3600);
claims.iss = identity.public_key();
claims.caps = vec!["/pub/chat/:re".parse().unwrap()];
stored.grant_jws = claims.sign(&identity, GRANT_JWS_TYP);
let signed =
super::super::approval_envelope::GrantApprovalEnvelope::sign(&identity, &claims);
stored.signed_approval = Some(SignedApproval::new(&signed));
let encoded = Zeroizing::new(stored.encode());
assert!(encoded.starts_with(STORED_GRANT_CREDENTIAL_APPROVAL_PREFIX));
let decoded = StoredGrantCredential::decode(&encoded).unwrap();
let (_, approval) = restore_verified_approval(
decoded.signed_approval.as_ref().map(SignedApproval::as_str),
&decoded.grant_jws,
)
.unwrap()
.unwrap();
let keys = approval.encryption_keys;
let path = pubky_common::StoragePath::new("/pub/chat/message").unwrap();
let expected = ScopedEncryptionKeyBundle::from_identity_secret(&identity.secret(), [&path]);
assert_eq!(
*keys.derive_for_path(&path).unwrap(),
*expected.derive_for_path(&path).unwrap()
);
keys.derive_for_path(&pubky_common::StoragePath::new("/pub/other/file").unwrap())
.unwrap_err();
assert!(!format!("{decoded:?}").contains(signed.as_str()));
let (other, _) = stored_credential(now_unix() + 3600);
restore_verified_approval(Some(&signed), &other.grant_jws).unwrap_err();
let mut tampered = signed.as_bytes().to_vec();
let signature_start = signed.rfind('.').unwrap() + 1;
tampered[signature_start] = if tampered[signature_start] == b'A' {
b'B'
} else {
b'A'
};
restore_verified_approval(
Some(std::str::from_utf8(&tampered).unwrap()),
&stored.grant_jws,
)
.unwrap_err();
}
fn expired_key_credential() -> (
StoredGrantCredential,
DelegatedGrantCredentialState,
Keypair,
) {
let identity = Keypair::random();
let (mut saved, mut claims) = stored_credential(1);
claims.iat = 0;
claims.iss = identity.public_key();
claims.caps = vec!["/pub/chat/:re".parse().unwrap()];
saved.grant_jws = claims.sign(&identity, GRANT_JWS_TYP);
let signed =
super::super::approval_envelope::GrantApprovalEnvelope::sign(&identity, &claims);
saved.signed_approval = Some(SignedApproval::new(&signed));
let delegated = DelegatedGrantCredentialState {
grant_jws: saved.grant_jws.clone(),
homeserver_pk: saved.homeserver_pk.clone(),
key_id: "missing-browser-key".into(),
client_pk: claims.cnf,
};
(saved, delegated, identity)
}
#[test]
fn offline_key_recovery_accepts_expiry_without_relaxing_session_restore() {
let (saved, delegated, identity) = expired_key_credential();
let token = Zeroizing::new(saved.encode());
let (local_claims, local_keys) = GrantCredential::restore_encryption_keys(&token)
.unwrap()
.unwrap();
let (delegated_claims, delegated_keys) =
GrantCredential::restore_encryption_keys_from_approval(
&delegated.grant_jws,
saved.signed_approval.as_ref().map(SignedApproval::as_str),
)
.unwrap()
.unwrap();
assert_eq!(local_claims.iss, identity.public_key());
assert_eq!(local_claims.jti, delegated_claims.jti);
assert_eq!(delegated_claims.iss, identity.public_key());
let path = pubky_common::StoragePath::new("/pub/chat/message").unwrap();
let expected = ScopedEncryptionKeyBundle::from_identity_secret(&identity.secret(), [&path]);
let shared_local = GrantCredential::from_shared_secret(&token).unwrap();
let shared_delegated = GrantCredential::from_shared_delegated_state(
delegated.clone(),
test_delegated_signer(),
saved.signed_approval.as_ref().map(SignedApproval::as_str),
)
.unwrap();
assert!(shared_local.signed_approval().is_some());
assert!(shared_delegated.signed_approval().is_some());
for keys in [
&local_keys,
&delegated_keys,
shared_local.encryption_keys().unwrap(),
shared_delegated.encryption_keys().unwrap(),
] {
assert_eq!(
*keys.derive_for_path(&path).unwrap(),
*expected.derive_for_path(&path).unwrap()
);
keys.derive_for_path(&pubky_common::StoragePath::new("/pub/other/file").unwrap())
.unwrap_err();
keys.derive_for_path(&pubky_common::StoragePath::new("/pub/chat/").unwrap())
.unwrap_err();
}
assert!(
restore_material(saved, false)
.unwrap_err()
.to_string()
.contains("has expired")
);
assert!(
restore_delegated_material(delegated, test_delegated_signer(), false, None)
.unwrap_err()
.to_string()
.contains("has expired")
);
}
#[test]
fn offline_key_recovery_rejects_tampering_and_mismatched_grants() {
let (mut saved, mut delegated, _) = expired_key_credential();
let signed = saved.signed_approval.clone().unwrap();
let (other, _) = stored_credential(now_unix() + 3600);
delegated.grant_jws = other.grant_jws.clone();
GrantCredential::restore_encryption_keys_from_approval(
&delegated.grant_jws,
Some(signed.as_str()),
)
.unwrap_err();
let original_grant = std::mem::replace(&mut saved.grant_jws, other.grant_jws);
GrantCredential::restore_encryption_keys(&saved.encode()).unwrap_err();
saved.grant_jws = original_grant;
delegated.grant_jws = saved.grant_jws.clone();
let mut tampered = signed.as_str().as_bytes().to_vec();
let signature_start = signed.as_str().rfind('.').unwrap() + 1;
tampered[signature_start] = if tampered[signature_start] == b'A' {
b'B'
} else {
b'A'
};
let tampered = Zeroizing::new(String::from_utf8(tampered).unwrap());
saved.signed_approval = Some(SignedApproval::new(&tampered));
GrantCredential::from_shared_secret(&saved.encode()).unwrap_err();
GrantCredential::from_shared_delegated_state(
delegated.clone(),
test_delegated_signer(),
Some(&tampered),
)
.unwrap_err();
GrantCredential::restore_encryption_keys(&saved.encode()).unwrap_err();
GrantCredential::restore_encryption_keys_from_approval(
&delegated.grant_jws,
Some(&tampered),
)
.unwrap_err();
GrantCredential::restore_encryption_keys("malformed").unwrap_err();
}
#[test]
fn offline_legacy_restore_returns_no_keys() {
let (saved, _) = stored_credential(1);
assert!(
GrantCredential::restore_encryption_keys(&saved.encode())
.unwrap()
.is_none()
);
let (_, delegated, _) = expired_key_credential();
assert!(
GrantCredential::restore_encryption_keys_from_approval(&delegated.grant_jws, None)
.unwrap()
.is_none()
);
}
#[test]
fn v2_secret_rejects_missing_approval() {
let (stored, _) = stored_credential(now_unix() + 3600);
let token = stored.encode().replacen(
STORED_GRANT_CREDENTIAL_PREFIX,
STORED_GRANT_CREDENTIAL_APPROVAL_PREFIX,
1,
);
StoredGrantCredential::decode(&token).unwrap_err();
StoredGrantCredential::decode(&format!("{token}:")).unwrap_err();
}
#[test]
fn restore_material_rejects_mismatched_client_key() {
let (mut stored, _claims) = stored_credential(now_unix() + 3600);
stored.client_key_secret = Keypair::random().secret();
let error = restore_material(stored, false).unwrap_err().to_string();
assert!(error.contains("client key does not match"));
}
#[test]
fn restore_delegated_material_rejects_mismatched_client_key() {
let (stored, _claims) = stored_credential(now_unix() + 3600);
let saved = DelegatedGrantCredentialState {
grant_jws: stored.grant_jws,
homeserver_pk: stored.homeserver_pk,
key_id: "delegated-test-key".into(),
client_pk: Keypair::random().public_key(),
};
let error = restore_delegated_material(saved, test_delegated_signer(), false, None)
.unwrap_err()
.to_string();
assert!(error.contains("client key does not match"));
}
#[test]
fn restore_delegated_material_rejects_expired_grant() {
let (stored, claims) = stored_credential(now_unix().saturating_sub(1));
let saved = DelegatedGrantCredentialState {
grant_jws: stored.grant_jws,
homeserver_pk: stored.homeserver_pk,
key_id: "delegated-test-key".into(),
client_pk: claims.cnf,
};
let error = restore_delegated_material(saved, test_delegated_signer(), false, None)
.unwrap_err()
.to_string();
assert!(error.contains("has expired"));
}
#[tokio::test]
async fn export_local_secret_is_only_available_for_local_signers() {
let (stored, claims) = stored_credential(now_unix() + 3600);
let local_signer = GrantPopSigner::local(Keypair::from_secret(&stored.client_key_secret));
let delegated_signer = GrantPopSigner::delegated(
"delegated-test-key".into(),
claims.cnf.clone(),
test_delegated_signer(),
);
let local = test_credential(stored.clone(), claims.clone(), local_signer);
let delegated = test_credential(stored, claims, delegated_signer);
assert!(local.export_local_secret().await.is_some());
assert!(delegated.export_local_secret().await.is_none());
assert!(delegated.export_delegated_restore_state().await.is_some());
}
#[test]
fn restore_material_rejects_expired_grant() {
let (stored, _claims) = stored_credential(now_unix().saturating_sub(1));
let error = restore_material(stored, false).unwrap_err().to_string();
assert!(error.contains("has expired"));
}
#[test]
fn shared_restore_keeps_expired_material_for_logout() {
let (stored, claims) = stored_credential(now_unix().saturating_sub(1));
GrantCredential::from_shared_secret(&stored.encode()).unwrap();
let delegated = DelegatedGrantCredentialState {
grant_jws: stored.grant_jws,
homeserver_pk: stored.homeserver_pk,
key_id: "delegated-test-key".into(),
client_pk: claims.cnf,
};
GrantCredential::from_shared_delegated_state(delegated, test_delegated_signer(), None)
.unwrap();
}
#[test]
fn stored_grant_credential_decode_rejects_wrong_prefix() {
let error = StoredGrantCredential::decode("wrong:v:secret:grant")
.unwrap_err()
.to_string();
assert!(error.contains("unsupported grant credential token version"));
}
#[tokio::test]
async fn refresh_server_failure_has_typed_secret_safe_provenance() {
let fixture = RequestFailureFixture::new(TestServerBehavior::Reply(
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 9\r\n\r\nsensitive".to_vec(),
))
.await;
let credential = fixture.grant_credential(true);
let error = credential.refresh(&fixture.client).await.unwrap_err();
assert!(
matches!(
&error,
Error::CredentialRefresh(CredentialRefreshError::Request {
status: Some(StatusCode::SERVICE_UNAVAILABLE)
})
),
"unexpected error: {error:?}"
);
let display = error.to_string();
assert_eq!(
display,
"Credential refresh failed: Grant credential refresh request failed"
);
let debug = format!("{error:?}");
for secret in [
"sensitive",
"test-bearer",
"auth/grant/session",
"localhost",
] {
assert!(!display.contains(secret));
assert!(!debug.contains(secret));
}
}
#[tokio::test]
async fn refresh_error_status_does_not_wait_for_response_body() {
let fixture = RequestFailureFixture::new_with_timeout(
TestServerBehavior::ReplyThenStall(
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 9\r\n\r\n".to_vec(),
),
Duration::from_secs(5),
)
.await;
let credential = fixture.grant_credential(true);
let error =
tokio::time::timeout(Duration::from_secs(1), credential.refresh(&fixture.client))
.await
.expect("refresh must discard the unread error body")
.unwrap_err();
assert!(matches!(
error,
Error::CredentialRefresh(CredentialRefreshError::Request {
status: Some(StatusCode::SERVICE_UNAVAILABLE)
})
));
}
#[tokio::test]
async fn refresh_json_decode_failure_preserves_response_status() {
let fixture = RequestFailureFixture::new(TestServerBehavior::Reply(
b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\nnot-json".to_vec(),
))
.await;
let credential = fixture.grant_credential(true);
let error = credential.refresh(&fixture.client).await.unwrap_err();
assert!(matches!(
error,
Error::CredentialRefresh(CredentialRefreshError::Request {
status: Some(StatusCode::OK)
})
));
}
#[tokio::test]
async fn refresh_timeout_has_typed_secret_safe_provenance() {
let fixture = RequestFailureFixture::new(TestServerBehavior::Stall).await;
let credential = fixture.grant_credential(true);
let error = credential.refresh(&fixture.client).await.unwrap_err();
assert!(
matches!(
&error,
Error::CredentialRefresh(CredentialRefreshError::Request { status: None })
),
"unexpected error: {error:?}"
);
let debug = format!("{error:?}");
assert!(!debug.contains("localhost"));
assert!(!debug.contains("auth/grant/session"));
}
#[tokio::test]
async fn shared_refresh_request_failure_uses_same_secret_safe_error() {
let fixture = RequestFailureFixture::new(TestServerBehavior::Reply(
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 9\r\n\r\nsensitive".to_vec(),
))
.await;
let credential = fixture.grant_credential(true);
let coordinator = TestCoordinator {
shared: Arc::new(tokio::sync::Mutex::new(Some(SharedGrantSession {
response: credential.state.lock().await.response(),
refresh_pending: false,
logout_pending: false,
}))),
};
let error = credential
.refresh_shared(&fixture.client, &coordinator, None)
.await
.unwrap_err();
assert!(matches!(
error,
Error::CredentialRefresh(CredentialRefreshError::Request {
status: Some(StatusCode::SERVICE_UNAVAILABLE)
})
));
}
#[tokio::test]
async fn refresh_preserves_local_authentication_failure() {
let (stored, claims) = stored_credential(now_unix() + 3600);
let credential = test_credential(
stored,
claims.clone(),
GrantPopSigner::delegated(
"missing-key".into(),
claims.cnf,
super::super::pop_signer::delegated_sign_callback(|_| async {
Err(super::super::pop_signer::GrantSigningError::KeyUnavailable(
"deleted key".into(),
))
}),
),
);
credential.state.lock().await.session.token_expires_at = now_unix();
let client = PubkyHttpClient::new().unwrap();
let error = credential.refresh(&client).await.unwrap_err();
assert!(matches!(
error,
Error::Authentication(AuthError::Validation(message)) if message.contains("deleted key")
));
}
#[tokio::test]
async fn shared_refresh_preserves_coordinator_request_failure() {
let (stored, claims) = stored_credential(now_unix() + 3600);
let signer = GrantPopSigner::local(Keypair::from_secret(&stored.client_key_secret));
let credential = test_credential(stored, claims, signer);
let client = PubkyHttpClient::new().unwrap();
let error = credential
.refresh_shared(&client, &FailingCoordinator, None)
.await
.unwrap_err();
assert!(matches!(
error,
Error::Request(RequestError::Validation { message }) if message == "browser storage failed"
));
}
#[tokio::test]
async fn shared_revalidation_treats_wrapped_missing_refresh_sessions_as_absent() {
for (status, reason) in [(401, "Unauthorized"), (404, "Not Found")] {
let response =
format!("HTTP/1.1 {status} {reason}\r\nContent-Length: 7\r\n\r\nprivate");
let fixture =
RequestFailureFixture::new(TestServerBehavior::Reply(response.into_bytes())).await;
let credential = fixture.grant_credential(true);
let shared = SharedGrantSession {
response: credential.state.lock().await.response(),
refresh_pending: false,
logout_pending: false,
};
credential.state.lock().await.coordinator = Some(Arc::new(TestCoordinator {
shared: Arc::new(tokio::sync::Mutex::new(Some(shared))),
}));
let result = credential
.revalidate(&fixture.client, &fixture.user.public_key())
.await;
assert!(
matches!(result, Ok(None)),
"wrapped {status} refresh failure must mean missing session, got {result:?}"
);
}
}
#[tokio::test]
async fn storage_server_failure_remains_request_error_for_all_credentials() {
for credential in [CredentialKind::Grant, CredentialKind::Cookie] {
let fixture = RequestFailureFixture::new(TestServerBehavior::Reply(
b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 7\r\n\r\nstorage".to_vec(),
))
.await;
let session = fixture.session(credential);
let error = session.storage().get("/pub/test/file").await.unwrap_err();
assert!(matches!(
error,
Error::Request(RequestError::Server {
status: StatusCode::INTERNAL_SERVER_ERROR,
..
})
));
}
}
#[tokio::test]
async fn storage_timeout_remains_request_error_for_all_credentials() {
for credential in [CredentialKind::Grant, CredentialKind::Cookie] {
let fixture = RequestFailureFixture::new(TestServerBehavior::Stall).await;
let session = fixture.session(credential);
let error = session.storage().get("/pub/test/file").await.unwrap_err();
assert!(matches!(
error,
Error::Request(RequestError::Transport(ref source)) if source.is_timeout()
));
}
}
#[derive(Clone, Copy)]
enum CredentialKind {
Grant,
Cookie,
}
#[derive(Debug)]
struct TestCoordinator {
shared: Arc<tokio::sync::Mutex<Option<SharedGrantSession>>>,
}
#[derive(Debug)]
struct FailingCoordinator;
#[async_trait]
impl GrantSessionCoordinator for FailingCoordinator {
async fn acquire(&self, _: bool) -> Result<Box<dyn GrantSessionLease>> {
Err(RequestError::Validation {
message: "browser storage failed".into(),
}
.into())
}
}
#[derive(Debug)]
struct TestLease {
shared: Arc<tokio::sync::Mutex<Option<SharedGrantSession>>>,
}
#[async_trait]
impl GrantSessionCoordinator for TestCoordinator {
async fn acquire(&self, _: bool) -> Result<Box<dyn GrantSessionLease>> {
Ok(Box::new(TestLease {
shared: Arc::clone(&self.shared),
}))
}
}
#[async_trait]
impl GrantSessionLease for TestLease {
async fn load(&self) -> Result<Option<SharedGrantSession>> {
Ok(self.shared.lock().await.clone())
}
async fn store(&self, session: &SharedGrantSession) -> Result<()> {
*self.shared.lock().await = Some(session.clone());
Ok(())
}
async fn remove(&self) -> Result<()> {
*self.shared.lock().await = None;
Ok(())
}
}
struct RequestFailureFixture {
client: PubkyHttpClient,
user: Keypair,
homeserver: pkarr::Keypair,
}
impl RequestFailureFixture {
async fn new(behavior: TestServerBehavior) -> Self {
Self::new_with_timeout(behavior, Duration::from_millis(100)).await
}
async fn new_with_timeout(behavior: TestServerBehavior, timeout: Duration) -> Self {
let port = spawn_https_test_server(behavior).await;
let homeserver = pkarr::Keypair::random();
let user = Keypair::random();
let cache = resolver_cache(&homeserver, &user, port);
let mut builder = PubkyHttpClient::builder();
builder
.isolated_pkarr_test()
.pkarr(|builder| builder.cache(cache))
.request_timeout(timeout)
.add_root_certificates_pem(include_bytes!("../../../client/test_certs/ca.pem"))
.unwrap();
let fixture = Self {
client: builder.build().unwrap(),
user,
homeserver,
};
fixture
.client
.features
.insert(&fixture.homeserver_pk(), &[]);
fixture
}
fn homeserver_pk(&self) -> PublicKey {
PublicKey::try_from_z32(&self.homeserver.public_key().to_string()).unwrap()
}
#[allow(
deprecated,
reason = "cookie credential is the supported non-grant control"
)]
fn session(&self, credential: CredentialKind) -> PubkySession {
match credential {
CredentialKind::Grant => PubkySession::from_grant_credential(
self.client.clone(),
self.grant_credential(false),
),
CredentialKind::Cookie => PubkySession::from_cookie_credential(
self.client.clone(),
self.cookie_credential(),
),
}
}
fn grant_credential(&self, refresh: bool) -> GrantCredential {
let client_keypair = Keypair::random();
let now = now_unix();
let claims = GrantClaims {
iss: self.user.public_key(),
client_id: ClientId::new("refresh-errors.test").unwrap(),
caps: vec![Capability::root()],
cnf: client_keypair.public_key(),
jti: GrantId::generate(),
iat: now,
exp: now + 3600,
};
let homeserver = self.homeserver_pk();
GrantCredential::from_response(
GrantSessionResponse {
token: "test-bearer".into(),
session: GrantSessionInfo {
homeserver: homeserver.clone(),
pubky: claims.iss.clone(),
client_id: claims.client_id.clone(),
capabilities: claims.caps.clone(),
grant_id: claims.jti.clone(),
token_expires_at: if refresh { now } else { now + 3600 },
grant_expires_at: claims.exp,
created_at: now,
},
},
claims.sign(&self.user, GRANT_JWS_TYP),
claims,
GrantPopSigner::local(client_keypair),
homeserver,
)
}
#[allow(
deprecated,
reason = "cookie credential is the supported non-grant control"
)]
fn cookie_credential(&self) -> CookieCredential {
let record = CookieSessionRecord::new(
&self.user.public_key(),
Capabilities::from(vec![Capability::root()]),
None,
);
CookieCredential::new(
self.user.public_key(),
Some("cookie-secret".into()),
record,
Some(self.homeserver_pk()),
)
}
}
enum TestServerBehavior {
Reply(Vec<u8>),
ReplyThenStall(Vec<u8>),
Stall,
}
async fn spawn_https_test_server(behavior: TestServerBehavior) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let acceptor = TlsAcceptor::from(Arc::new(test_server_config()));
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut stream = acceptor.accept(stream).await.unwrap();
if !read_request_headers(&mut stream).await {
return;
}
match behavior {
TestServerBehavior::Reply(response) => stream.write_all(&response).await.unwrap(),
TestServerBehavior::ReplyThenStall(response) => {
stream.write_all(&response).await.unwrap();
tokio::time::sleep(Duration::from_secs(2)).await;
}
TestServerBehavior::Stall => tokio::time::sleep(Duration::from_secs(1)).await,
}
});
port
}
fn test_server_config() -> rustls::ServerConfig {
let server_cert =
CertificateDer::from_pem_slice(include_bytes!("../../../client/test_certs/server.pem"))
.unwrap();
let server_key =
PrivateKeyDer::from_pem_slice(include_bytes!("../../../client/test_certs/server.key"))
.unwrap();
rustls::ServerConfig::builder_with_provider(Arc::new(
rustls::crypto::aws_lc_rs::default_provider(),
))
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![server_cert], server_key)
.unwrap()
}
async fn read_request_headers(stream: &mut (impl AsyncRead + Unpin)) -> bool {
let mut request = Vec::new();
let mut buffer = [0; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let read = stream.read(&mut buffer).await.unwrap();
if read == 0 {
return false;
}
request.extend_from_slice(&buffer[..read]);
}
true
}
fn resolver_cache(homeserver: &pkarr::Keypair, user: &Keypair, port: u16) -> Arc<dyn Cache> {
let cache: Arc<dyn Cache> = Arc::new(InMemoryCache::new(NonZeroUsize::new(2).unwrap()));
let mut endpoint = SVCB::new(10, "localhost".try_into().unwrap());
endpoint.set_port(port);
let homeserver_packet = SignedPacket::builder()
.https(".".try_into().unwrap(), endpoint, 3600)
.sign(homeserver)
.unwrap();
cache.put(&homeserver.public_key().into(), &homeserver_packet);
let homeserver_z32 = homeserver.public_key().to_string();
let alias = SVCB::new(0, homeserver_z32.as_str().try_into().unwrap());
let user_pkarr = pkarr::PublicKey::try_from(user.public_key().z32().as_str()).unwrap();
let user_packet = SignedPacket::builder()
.https("_pubky".try_into().unwrap(), alias, 3600)
.sign(&pkarr::Keypair::from_secret_key(&user.secret()))
.unwrap();
cache.put(&user_pkarr.into(), &user_packet);
cache
}
fn stored_credential(exp: u64) -> (StoredGrantCredential, GrantClaims) {
let user_keypair = Keypair::random();
let client_keypair = Keypair::random();
let homeserver_keypair = Keypair::random();
let claims = GrantClaims {
iss: user_keypair.public_key(),
client_id: ClientId::new("stored-grant.test").unwrap(),
caps: vec![Capability::root()],
cnf: client_keypair.public_key(),
jti: GrantId::generate(),
iat: now_unix(),
exp,
};
let grant_jws = claims.sign(&user_keypair, GRANT_JWS_TYP);
let stored = StoredGrantCredential {
grant_jws,
client_key_secret: client_keypair.secret(),
homeserver_pk: homeserver_keypair.public_key(),
signed_approval: None,
};
(stored, claims)
}
fn test_credential(
stored: StoredGrantCredential,
claims: GrantClaims,
client_signer: GrantPopSigner,
) -> GrantCredential {
let now = now_unix();
GrantCredential::from_response(
GrantSessionResponse {
token: "test-bearer".into(),
session: GrantSessionInfo {
homeserver: stored.homeserver_pk.clone(),
pubky: claims.iss.clone(),
client_id: claims.client_id.clone(),
capabilities: claims.caps.clone(),
grant_id: claims.jti.clone(),
token_expires_at: now + 300,
grant_expires_at: claims.exp,
created_at: now,
},
},
stored.grant_jws,
claims,
client_signer,
stored.homeserver_pk,
)
}
fn test_delegated_signer() -> DelegatedSignFn {
super::super::pop_signer::delegated_sign_callback(|_| async { Ok(vec![0; 64]) })
}
#[tokio::test]
async fn can_attach_to_only_matches_bound_homeserver() {
let bound = Keypair::random().public_key();
let other = Keypair::random().public_key();
let (mut stored, claims) = stored_credential(now_unix() + 3600);
stored.homeserver_pk = bound.clone();
let client_signer = GrantPopSigner::local(Keypair::from_secret(&stored.client_key_secret));
let credential = test_credential(stored, claims, client_signer);
assert!(
credential.can_attach_to(&bound).await,
"grant credential must attach to the homeserver it was minted for"
);
assert!(
!credential.can_attach_to(&other).await,
"grant credential must NOT attach to a homeserver it was not minted for"
);
}
#[tokio::test]
async fn grant_session_request_routes_through_bound_homeserver() {
use pkarr::{SignedPacket, dns::rdata::SVCB};
let (mut stored, claims) = stored_credential(now_unix() + 3600);
let homeserver_keypair = pkarr::Keypair::random();
stored.homeserver_pk =
PublicKey::try_from_z32(&homeserver_keypair.public_key().to_string()).unwrap();
let icann = SVCB::new(10, "example.com".try_into().unwrap());
let homeserver_packet = SignedPacket::builder()
.https(".".try_into().unwrap(), icann, 3600)
.sign(&homeserver_keypair)
.unwrap();
let client_signer = GrantPopSigner::local(Keypair::from_secret(&stored.client_key_secret));
let credential = test_credential(stored, claims.clone(), client_signer);
let cache = Arc::new(InMemoryCache::new(NonZeroUsize::MIN));
let mut builder = PubkyHttpClient::builder();
builder
.isolated_pkarr_test()
.pkarr(|b| b.cache(Arc::<InMemoryCache>::clone(&cache)));
let client = builder.build().unwrap();
cache.put(&homeserver_keypair.public_key().into(), &homeserver_packet);
let request = credential
.grant_session_request(&client, Method::POST)
.await
.unwrap()
.build()
.unwrap();
assert_eq!(request.url().host_str(), Some("example.com"));
assert_eq!(request.url().path(), "/auth/grant/session");
assert_eq!(
request.headers().get("pubky-host").unwrap(),
&claims.iss.z32()
);
}
}