1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
//! grant-only capability view — type-safe access to grant-specific operations.
//!
//! [`GrantSessionView`] is obtained via
//! [`PubkySession::as_grant`](crate::actors::session::core::PubkySession::as_grant).
//! The view borrows the session, so it cannot outlive it; this is what makes
//! the grant-only API impossible to misuse against a cookie session.
use pubky_common::{
auth::{grant_session_responses::GrantSessionInfo, jws::GrantId},
encryption_keys::ScopedEncryptionKeyBundle,
};
use super::{CustomPopError, DelegatedGrantCredentialState, GrantCredential};
use crate::actors::session::core::PubkySession;
use crate::custom_pop::CustomPop;
use crate::errors::Result;
/// grant-only operations on a [`PubkySession`].
#[derive(Debug)]
pub struct GrantSessionView<'a> {
session: &'a PubkySession,
credential: &'a GrantCredential,
}
impl PubkySession {
/// Returns a [`GrantSessionView`] if this session is grant-backed.
///
/// grant-only operations (`session_info`, `export_secret`,
/// `current_bearer`, `force_refresh`, `grant_id`) live on the view.
/// Cookie-backed sessions return `None`.
#[must_use]
pub fn as_grant(&self) -> Option<GrantSessionView<'_>> {
self.try_downcast_credential::<GrantCredential>()
.map(|c| GrantSessionView::new(self, c))
}
}
impl<'a> GrantSessionView<'a> {
/// Sign arbitrary JSON and return both the custom proof and its root-signed grant.
///
/// Makes no network requests and does not require a fresh homeserver bearer.
/// Applications define the data's meaning and handle freshness and replay protection.
/// Future grant issue times are checked by the verifier using its clock-skew policy.
///
/// # Errors
/// Returns [`CustomPopError`](crate::custom_pop::CustomPopError) for expired or unusable
/// grants, unavailable signing keys, and signing failures. Browser coordination
/// and lifecycle failures retain their source in its `SessionState` variant.
pub async fn create_custom_pop(
&self,
data: serde_json::Value,
) -> std::result::Result<CustomPop, CustomPopError> {
self.credential.create_custom_pop(data).await
}
pub(crate) const fn new(session: &'a PubkySession, credential: &'a GrantCredential) -> Self {
Self {
session,
credential,
}
}
/// Verified scoped keys retained by this session's grant credential.
///
/// Bare grants return `None`; signed approvals return `Some`, with an empty
/// bundle when no `e` scopes were approved. Secret tokens of signed
/// approvals (`pubky-grant-credential-v2`) preserve the bundle; bare-grant
/// tokens have no keys.
///
/// The signer may narrow the requested scopes or decline `e`, so check the
/// approved scopes before deriving keys. Keys stay usable after the grant
/// expires or is revoked; see the
/// [scoped encryption keys guide](https://github.com/pubky/pubky-homeserver/blob/main/docs/scoped-encryption-keys.md).
///
/// ```no_run
/// # use pubky::{PubkySession, StoragePath};
/// # fn use_keys(session: &PubkySession) -> Result<(), Box<dyn std::error::Error>> {
/// let grant = session.as_grant().expect("grant-backed session");
/// let chat = StoragePath::new("/pub/chat/")?;
/// if let Some(keys) = grant.encryption_keys() {
/// if keys.scopes().any(|scope| scope == &chat) {
/// let path = StoragePath::new("/pub/chat/message")?;
/// let key = keys.derive_for_path(&path)?; // 32 bytes, wiped on drop.
/// }
/// }
/// # Ok(()) }
/// ```
pub fn encryption_keys(&self) -> Option<&ScopedEncryptionKeyBundle> {
self.credential.encryption_keys()
}
/// Borrow the confidential signed approval for secure browser persistence.
///
/// May contain scoped secret keys. Store separately from non-secret delegated
/// metadata. Use [`GrantCredential::restore_encryption_keys_from_approval`] for
/// offline key recovery, or import the credential to authenticate again.
pub fn signed_approval(&self) -> Option<&str> {
self.credential.signed_approval()
}
/// Returns the full grant session metadata from the homeserver.
///
/// This gives access to grant-specific fields like `grant_id`,
/// `client_id`, `token_expires_at`, and `grant_expires_at` that are
/// not available via the shared
/// [`PubkySession::info`](crate::actors::session::core::PubkySession::info)
/// accessor.
pub async fn session_info(&self) -> GrantSessionInfo {
self.credential.state.lock().await.session.clone()
}
/// Export the portable local secret material needed to restore this session.
///
/// The returned token contains the grant JWS and `PoP` client secret. Treat
/// it as a bearer-equivalent secret. Sessions with signed approvals include
/// those approvals, even with an empty key bundle. Delivered keys remain
/// sensitive after expiry or revocation.
/// Delegated/browser-held `PoP` keys return `None` because the private key
/// is intentionally not extractable.
pub async fn export_local_secret(&self) -> Option<String> {
self.credential.export_local_secret().await
}
/// Export non-secret delegated restore metadata, if this session uses a
/// browser-held delegated `PoP` key.
/// Scoped keys are omitted; persist [`Self::signed_approval`] separately
/// to restore keys alongside authentication.
pub async fn export_delegated_restore_state(&self) -> Option<DelegatedGrantCredentialState> {
self.credential.export_delegated_restore_state().await
}
/// Returns the current opaque bearer for this session.
pub async fn current_bearer(&self) -> String {
self.credential.current_bearer().await
}
/// Returns the grant id (`jti`) backing this session, for callers that
/// need to revoke or display it.
pub async fn grant_id(&self) -> GrantId {
self.credential.state.lock().await.grant_claims.jti.clone()
}
/// Refresh shared credential state if its bearer is near expiry.
///
/// Browser restore calls this to keep existing handles usable.
/// # Errors
/// Propagates grant exchange errors.
#[doc(hidden)]
pub async fn refresh_if_needed(&self) -> Result<()> {
self.credential.refresh(self.session.client()).await
}
/// Attach browser coordination to this session and its existing clones.
#[doc(hidden)]
pub async fn coordinate(
&self,
coordinator: std::sync::Arc<dyn crate::GrantSessionCoordinator>,
lease: &dyn crate::GrantSessionLease,
) -> Result<()> {
self.credential.coordinate(coordinator, lease).await
}
/// Test/debug helper: force a refresh of the credential right now.
///
/// Used by integration tests to verify that a refresh yields a new
/// bearer. Returns the new bearer for assertions.
///
/// Bypasses the proactive-refresh time check so the refresh always runs.
///
/// # Errors
/// - Propagates HTTP errors from the refresh exchange.
#[doc(hidden)]
pub async fn force_refresh(&self) -> Result<String> {
if let Some(coordinator) = self.credential.coordinator().await {
let bearer = self.credential.current_bearer().await;
self.credential
.refresh_shared(self.session.client(), coordinator.as_ref(), Some(&bearer))
.await?;
return Ok(self.credential.current_bearer().await);
}
// Bypass the proactive-refresh time check by setting the expiry
// to 0; the refresh helper then always hits the network.
self.credential.state.lock().await.session.token_expires_at = 0;
self.credential.refresh(self.session.client()).await?;
Ok(self.credential.state.lock().await.bearer.clone())
}
}