From 2403278b208760b9ad0fbfac99d4058c3e3c5c2a Mon Sep 17 00:00:00 2001 From: dzdidi Date: Tue, 4 Aug 2026 08:26:24 -0300 Subject: [PATCH 1/5] feat(js-sdk): expose authenticated session metadata --- .../js/scripts/smoke-generated-api.mjs | 1 + locks-sdk/bindings/js/src/locks.rs | 66 +++++++++++++++++-- locks-sdk/bindings/js/src/session.rs | 30 +++++++++ locks-sdk/bindings/js/src/viewer.rs | 38 +++++++++-- 4 files changed, 122 insertions(+), 13 deletions(-) diff --git a/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs b/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs index dec49c6..8106350 100644 --- a/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs +++ b/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs @@ -60,6 +60,7 @@ const requiredSnippets = [ 'lookupVerificationTask(options: VerificationTaskHandleOptions): Promise;', 'issueAccessCredential(options: VerificationTaskHandleOptions): Promise;', 'proxyReadGuardedResource(access_credential: string, path: string): Promise;', + 'proxyReadGuardedResourceResponse(access_credential: string, path: string): Promise;', 'export class VerificationTaskHandleOptions', 'constructor(creator: string, bundle_id: string);', 'export class Session', diff --git a/locks-sdk/bindings/js/src/locks.rs b/locks-sdk/bindings/js/src/locks.rs index 3788884..4c3f7b2 100644 --- a/locks-sdk/bindings/js/src/locks.rs +++ b/locks-sdk/bindings/js/src/locks.rs @@ -343,11 +343,12 @@ impl Locks { .prepare_exchange_request_with_pkarr_resolver(&request, &resolver, None) .await .map_err(|err| invalid_input(err.to_string()))?; - let token = post_json_for_session_token(&request).await?; - Ok(Session::new( - self.inner.restore_session(token), + let response = post_json_for_session(&request).await?; + Ok(Session::new_with_creator( + self.inner.restore_session(&response.session_token), self.inner.clone(), self.options.clone(), + Some(response.creator.to_string()), )) } } @@ -684,7 +685,9 @@ async fn fetch_content_lock_json(request_plan: &JsPreparedContentLockRequest) -> } #[cfg(target_arch = "wasm32")] -async fn post_json_for_session_token(request_plan: &JsPreparedRequest) -> JsResult { +async fn post_json_for_session( + request_plan: &JsPreparedRequest, +) -> JsResult { let request_init = web_sys::RequestInit::new(); request_init.set_method(request_plan.method); request_init.set_mode(web_sys::RequestMode::Cors); @@ -725,17 +728,68 @@ async fn post_json_for_session_token(request_plan: &JsPreparedRequest) -> JsResu .map_err(|err| invalid_input(format!("failed to parse JSON response: {err:?}")))?; let value: Value = serde_wasm_bindgen::from_value(json) .map_err(|err| invalid_input(format!("invalid session response JSON: {err}")))?; - value + parse_frontend_session_response(value).map_err(invalid_input) +} + +#[cfg(any(test, target_arch = "wasm32"))] +#[derive(Debug, Clone, PartialEq, Eq)] +struct FrontendSessionResponse { + session_token: String, + creator: CreatorPubky, +} + +#[cfg(any(test, target_arch = "wasm32"))] +fn parse_frontend_session_response(value: Value) -> Result { + let session_token = value .get("session_token") .and_then(Value::as_str) .map(ToOwned::to_owned) - .ok_or_else(|| invalid_input("frontend session response missing session_token")) + .ok_or_else(|| "frontend session response missing session_token".to_owned())?; + let creator = value + .get("creator") + .and_then(Value::as_str) + .ok_or_else(|| "frontend session response missing creator".to_owned()) + .and_then(|creator| { + CreatorPubky::from_str(creator) + .map_err(|_| "frontend session response contains invalid creator".to_owned()) + })?; + Ok(FrontendSessionResponse { + session_token, + creator, + }) } #[cfg(test)] mod tests { use super::*; + #[test] + fn frontend_session_response_preserves_authenticated_creator() { + let response = parse_frontend_session_response(serde_json::json!({ + "session_token": "frontend-session-secret", + "creator": "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy", + "expires_at": "2030-01-01T00:00:00Z" + })) + .unwrap(); + + assert_eq!(response.session_token, "frontend-session-secret"); + assert_eq!( + response.creator.to_string(), + "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy" + ); + } + + #[test] + fn frontend_session_response_rejects_missing_creator() { + let error = parse_frontend_session_response(serde_json::json!({ + "session_token": "frontend-session-secret", + "expires_at": "2030-01-01T00:00:00Z" + })) + .unwrap_err(); + + assert!(error.contains("missing creator")); + } + #[test] fn locks_constructor_is_available_for_valid_lock_server_pubky() { let locks = diff --git a/locks-sdk/bindings/js/src/session.rs b/locks-sdk/bindings/js/src/session.rs index ac68fb7..575adad 100644 --- a/locks-sdk/bindings/js/src/session.rs +++ b/locks-sdk/bindings/js/src/session.rs @@ -240,6 +240,7 @@ impl JsAuthorizedRequestPlan { pub struct Session { inner: locks_sdk::LocksSession, client: locks_sdk::LocksClient, + creator_pubky: Option, #[cfg_attr(not(any(test, target_arch = "wasm32")), allow(dead_code))] options: LocksOptions, } @@ -256,6 +257,11 @@ impl Session { self.client.lock_server().to_string() } + #[wasm_bindgen(js_name = creatorPubky)] + pub fn creator_pubky(&self) -> Option { + self.creator_pubky.clone() + } + #[wasm_bindgen(getter)] pub fn creator(&self) -> Creator { Creator::new(self.clone()) @@ -279,10 +285,20 @@ impl Session { inner: locks_sdk::LocksSession, client: locks_sdk::LocksClient, options: LocksOptions, + ) -> Self { + Self::new_with_creator(inner, client, options, None) + } + + pub(crate) fn new_with_creator( + inner: locks_sdk::LocksSession, + client: locks_sdk::LocksClient, + options: LocksOptions, + creator_pubky: Option, ) -> Self { Self { inner, client, + creator_pubky, options, } } @@ -510,6 +526,20 @@ mod tests { ); } + #[test] + fn exchanged_session_exposes_authenticated_creator_pubky() { + let client = test_client(); + let creator = "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy"; + let session = Session::new_with_creator( + client.restore_session("frontend-session-secret"), + client, + LocksOptions::new(), + Some(creator.to_owned()), + ); + + assert_eq!(session.creator_pubky(), Some(creator.to_owned())); + } + #[test] fn signout_request_uses_current_frontend_session_endpoint_and_bearer() { let client = test_client(); diff --git a/locks-sdk/bindings/js/src/viewer.rs b/locks-sdk/bindings/js/src/viewer.rs index e7afb7d..a9cab03 100644 --- a/locks-sdk/bindings/js/src/viewer.rs +++ b/locks-sdk/bindings/js/src/viewer.rs @@ -210,6 +210,23 @@ impl Viewer { .map_err(|err| invalid_input(err.to_string()))?; fetch_viewer_bytes(&request).await } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen(js_name = proxyReadGuardedResourceResponse)] + pub async fn proxy_read_guarded_resource_response( + &self, + access_credential: &str, + path: String, + ) -> JsResult { + let resolver = BrowserPkarrResolver::new_with_options(&self.options) + .map_err(|err| invalid_input(err.to_string()))?; + let request = self + .build_proxy_read_guarded_resource_request(access_credential, path) + .prepare_with_pkarr_resolver(&resolver, None) + .await + .map_err(|err| invalid_input(err.to_string()))?; + fetch_viewer_response(&request).await + } } impl Viewer { @@ -408,13 +425,8 @@ async fn fetch_viewer_json_value(request: &JsPreparedViewerRequest) -> JsResult< #[cfg(target_arch = "wasm32")] async fn fetch_viewer_bytes(request: &JsPreparedViewerRequest) -> JsResult { - let response = fetch_viewer(request).await?; - if !response.ok() { - return Err(invalid_input(format!( - "Lock Server viewer request failed with HTTP {}", - response.status() - ))); - } + let response = fetch_viewer_response(request).await?; + let buffer = wasm_bindgen_futures::JsFuture::from( response .array_buffer() @@ -425,6 +437,18 @@ async fn fetch_viewer_bytes(request: &JsPreparedViewerRequest) -> JsResult JsResult { + let response = fetch_viewer(request).await?; + if !response.ok() { + return Err(invalid_input(format!( + "Lock Server viewer request failed with HTTP {}", + response.status() + ))); + } + Ok(response) +} + #[cfg(target_arch = "wasm32")] async fn fetch_viewer(request: &JsPreparedViewerRequest) -> JsResult { use wasm_bindgen::JsCast; From 0dc46172713e55c3a5bcf4912d72123f4d57facf Mon Sep 17 00:00:00 2001 From: dzdidi Date: Tue, 4 Aug 2026 08:27:49 -0300 Subject: [PATCH 2/5] fix(auth): harden iframe callback delivery --- locks-e2e/tests/legacy_connect_shell_http.rs | 4 ++++ locks-server/src/api/creator_authority.rs | 25 ++++++++------------ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/locks-e2e/tests/legacy_connect_shell_http.rs b/locks-e2e/tests/legacy_connect_shell_http.rs index 29c3bdd..dc71e2c 100644 --- a/locks-e2e/tests/legacy_connect_shell_http.rs +++ b/locks-e2e/tests/legacy_connect_shell_http.rs @@ -158,6 +158,10 @@ async fn connect_shell_postmessage_mode_returns_json_and_frames_allowed_parent() // the callback message type, and drops the manual approval button. assert!(shell_html.contains("locks-auth-callback")); assert!(shell_html.contains("TARGET_ORIGIN = \"https://pubky.app\"")); + assert!(shell_html.contains("CALLBACK_STATE = \"opaque-state\"")); + assert!(shell_html.contains("error: \"invalid-response\"")); + assert!(shell_html.contains("error: \"connect-failed\"")); + assert!(!shell_html.contains("connect-failed-\" + res.status")); assert!(!shell_html.contains("I approved this connection")); assert!(!shell_html.contains(" String { let escaped_flow_id = escape_html(flow_id); let escaped_authorization_url = escape_html(authorization_url); @@ -218,7 +211,7 @@ fn render_connect_shell_html( match delivery { // Embedded in the parent app's modal: render only the QR, transparent, no card/title/close. ConnectDeliveryMode::PostMessage => { - let script = render_postmessage_script(flow_id, target_origin); + let script = render_postmessage_script(flow_id, target_origin, callback_state); format!( r#" @@ -254,19 +247,21 @@ fn render_connect_shell_html( /// approval). Before approval the endpoint is effectively idempotent (the pending flow still /// exists), so transient failures — a dropped connection or a gateway timeout from a proxy that /// capped the idle long-poll — are retried with capped exponential backoff. A definitive error -/// (expired/rejected flow) is surfaced to the parent as `{ type, error }` so the embedder is never +/// (expired/rejected flow) is surfaced as a closed `{ type, state, error }` message so the embedder is never /// left hanging. On success it posts `{ type, state, code }` and stops. -fn render_postmessage_script(flow_id: &str, target_origin: &str) -> String { +fn render_postmessage_script(flow_id: &str, target_origin: &str, callback_state: &str) -> String { let flow_id_js = js_string_literal(flow_id); let target_origin_js = js_string_literal(target_origin); let type_js = js_string_literal(POSTMESSAGE_CALLBACK_TYPE); let resize_type_js = js_string_literal(POSTMESSAGE_RESIZE_TYPE); + let callback_state_js = js_string_literal(callback_state); format!( r#"