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},
};
use reqwest::{Method, RequestBuilder};
use tokio::sync::Mutex;
use super::grant_exchange::credential_from_grant_exchange;
use crate::actors::session::core::PubkySession;
use crate::actors::session::credential::{SessionCredential, credential_session_missing};
use crate::{
PubkyHttpClient,
actors::session::SessionInfo,
actors::storage::resource::resolve_pubky,
cross_log,
errors::{AuthError, RequestError, Result},
util::check_http_status,
};
pub(crate) const REFRESH_SLACK_SECS: u64 = 300;
const STORED_GRANT_CREDENTIAL_PREFIX: &str = "pubky-grant-credential-v1";
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 token_expires_at: u64,
pub grant_jws: String,
pub grant_claims: GrantClaims,
pub client_keypair: Keypair,
pub homeserver_pk: PublicKey,
pub session: GrantSessionInfo,
}
impl GrantCredentialState {
fn is_near_expiry(&self, now: u64, slack: u64) -> bool {
self.token_expires_at.saturating_sub(slack) <= now
}
}
#[derive(Clone, Debug)]
pub struct GrantCredential {
pub(crate) state: Arc<Mutex<GrantCredentialState>>,
pub(crate) info: SessionInfo,
}
#[derive(Clone, PartialEq, Eq)]
struct StoredGrantCredential {
grant_jws: String,
client_key_secret: [u8; 32],
homeserver_pk: PublicKey,
}
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)
.finish()
}
}
impl StoredGrantCredential {
#[must_use]
fn encode(&self) -> String {
let secret = URL_SAFE_NO_PAD.encode(self.client_key_secret);
format!(
"{STORED_GRANT_CREDENTIAL_PREFIX}:{}:{secret}:{}",
self.homeserver_pk.z32(),
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 {
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)?;
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 = 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,
})
}
}
impl GrantCredential {
pub(crate) fn from_response(
response: GrantSessionResponse,
grant_jws: String,
grant_claims: GrantClaims,
client_keypair: Keypair,
homeserver_pk: PublicKey,
) -> Self {
let info = to_session_info(&response.session);
let state = GrantCredentialState {
bearer: response.token,
token_expires_at: response.session.token_expires_at,
grant_jws,
grant_claims,
client_keypair,
homeserver_pk,
session: response.session,
};
Self {
state: Arc::new(Mutex::new(state)),
info,
}
}
pub(crate) async fn current_bearer(&self) -> String {
self.state.lock().await.bearer.clone()
}
pub async fn export_secret(&self) -> String {
let state = self.state.lock().await;
StoredGrantCredential {
grant_jws: state.grant_jws.clone(),
client_key_secret: state.client_keypair.secret(),
homeserver_pk: state.homeserver_pk.clone(),
}
.encode()
}
pub(crate) fn is_secret_token(token: &str) -> bool {
token.starts_with(STORED_GRANT_CREDENTIAL_PREFIX_FAMILY)
}
pub async fn import_secret(token: &str, client: &PubkyHttpClient) -> Result<Self> {
let saved = StoredGrantCredential::decode(token)?;
let (grant_jws, grant_claims, client_keypair, homeserver_pk) = restore_material(saved)?;
credential_from_grant_exchange(
client,
grant_jws,
grant_claims,
client_keypair,
homeserver_pk,
)
.await
}
pub(crate) async fn refresh(&self, client: &PubkyHttpClient) -> Result<()> {
cross_log!(info, "Refreshing grant credential");
let mut state = self.state.lock().await;
if !state.is_near_expiry(now_unix(), REFRESH_SLACK_SECS / 2) {
return Ok(());
}
let pop_jws = sign_pop_for_grant(
&state.client_keypair,
&state.homeserver_pk,
&state.grant_claims.jti,
);
let body = serde_json::json!({ "grant": &state.grant_jws, "pop": pop_jws });
let url = format!("pubky{}/auth/grant/session", state.grant_claims.iss.z32());
let resolved = resolve_pubky(&url)?;
let resp = client
.cross_request(Method::POST, resolved)
.await?
.json(&body)
.send()
.await?;
let resp = check_http_status(resp).await?;
let parsed: GrantSessionResponse =
resp.json().await.map_err(|e| RequestError::DecodeJson {
message: format!("decoding /auth/grant/session response: {e}"),
})?;
state.bearer = parsed.token;
state.token_expires_at = parsed.session.token_expires_at;
state.session = parsed.session;
Ok(())
}
}
#[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 user_pk = self.state.lock().await.grant_claims.iss.clone();
let url = format!("pubky{}/auth/grant/session", user_pk.z32());
let resolved = resolve_pubky(&url)?;
let bearer = self.current_bearer().await;
let response = client
.cross_request(Method::DELETE, resolved)
.await?
.bearer_auth(&bearer)
.send()
.await
.map_err(crate::Error::from)?;
check_http_status(response).await?;
Ok(())
}
async fn attach(&self, rb: RequestBuilder, client: &PubkyHttpClient) -> Result<RequestBuilder> {
let needs_refresh = {
let grant_state = self.state.lock().await;
grant_state.is_near_expiry(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))
}
async fn revalidate(
&self,
client: &PubkyHttpClient,
_user: &PublicKey,
) -> Result<Option<SessionInfo>> {
let user_pk = self.state.lock().await.grant_claims.iss.clone();
let url = format!("pubky{}/auth/grant/session", user_pk.z32());
let resolved = resolve_pubky(&url)?;
let bearer = self.current_bearer().await;
let response = client
.cross_request(Method::GET, resolved)
.await?
.bearer_auth(&bearer)
.send()
.await
.map_err(crate::Error::from)?;
if credential_session_missing(&response) {
return Ok(None);
}
let response = 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,
) -> Result<(String, GrantClaims, Keypair, PublicKey)> {
let grant_claims = GrantClaims::decode(&saved.grant_jws).map_err(|err| {
AuthError::Validation(format!("invalid stored grant credential grant JWS: {err}"))
})?;
if 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((
saved.grant_jws,
grant_claims,
client_keypair,
saved.homeserver_pk,
))
}
fn invalid_stored_grant() -> AuthError {
AuthError::Validation(format!(
"invalid stored grant credential: expected `{STORED_GRANT_CREDENTIAL_PREFIX}:<homeserver>:<client_secret>:<grant_jws>`"
))
}
pub(crate) fn sign_pop_for_grant(
client_keypair: &Keypair,
homeserver_pk: &PublicKey,
grant_id: &pubky_common::auth::jws::GrantId,
) -> String {
let claims = PopProofClaims {
aud: homeserver_pk.clone(),
gid: grant_id.clone(),
nonce: PopNonce::generate(),
iat: now_unix(),
};
pubky_common::auth::jws::sign_jws(client_keypair, POP_JWS_TYP, &claims)
}
#[cfg(test)]
mod tests {
use pubky_common::{
auth::jws::{ClientId, GRANT_JWS_TYP, GrantId},
capabilities::Capability,
};
use super::*;
#[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, stored);
}
#[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).unwrap_err().to_string();
assert!(error.contains("client key does not match"));
}
#[test]
fn restore_material_rejects_expired_grant() {
let (stored, _claims) = stored_credential(now_unix().saturating_sub(1));
let error = restore_material(stored).unwrap_err().to_string();
assert!(error.contains("has expired"));
}
#[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"));
}
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(),
};
(stored, claims)
}
}