Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions pubky-sdk/bindings/js/pkg/tests/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,157 @@ test("startAuthFlow: rejects malformed capabilities; normalizes valid; allows em
t.end();
});

test("Auth: resume signin flow reconnects to same channel", async (t) => {
const sdk = Pubky.testnet();

const signer = sdk.signer(Keypair.random());
const pubky = signer.publicKey.z32();

const capabilities = "/pub/pubky.app/:rw,/pub/foo.bar/file:r";

// 1) Start a flow and save the URL (as the app would before a refresh).
const originalFlow = sdk.startAuthFlow(capabilities, AuthFlowKind.signin(), TESTNET_HTTP_RELAY);
const savedUrl = originalFlow.authorizationUrl;
// Explicitly free the WASM handle — simulates page refresh killing WASM memory.
// JS block scoping does NOT trigger WASM destructors; .free() does.
originalFlow.free();

// 2) Signer approves while the original flow is gone (token waits in the relay inbox).
{
const signupToken = await createSignupToken();
await signer.signup(HOMESERVER_PUBLICKEY, signupToken);
await signer.approveAuthRequest(savedUrl);
}

// 3) Resume from the saved URL — reconnects to the same relay channel.
const resumedFlow = sdk.resumeAuthFlow(savedUrl);

t.equal(
resumedFlow.authorizationUrl,
savedUrl,
"resumed flow produces the same authorization URL",
);

const session = await resumedFlow.awaitApproval();

t.equal(
session.info.publicKey.z32(),
pubky,
"resumed flow session belongs to expected user",
);
t.deepEqual(
session.info.capabilities,
capabilities.split(","),
"resumed flow session capabilities match",
);

t.end();
});

test("Auth: resume signup flow preserves signup params and channel", async (t) => {
const sdk = Pubky.testnet();

const signer = sdk.signer(Keypair.random());
const pubky = signer.publicKey.z32();
const signupToken = await createSignupToken();

const capabilities = "/pub/pubky.app/:rw,/pub/foo.bar/file:r";

// 1) Start a signup flow and save URL before refresh.
const originalFlow = sdk.startAuthFlow(
capabilities,
AuthFlowKind.signup(HOMESERVER_PUBLICKEY, signupToken),
TESTNET_HTTP_RELAY,
);
const savedUrl = originalFlow.authorizationUrl;

// Signup-specific params are present in the persisted URL.
const savedParsed = new URL(savedUrl);
t.equal(
savedParsed.searchParams.get("hs"),
HOMESERVER_PUBLICKEY.z32(),
"saved URL keeps homeserver parameter",
);
t.equal(
savedParsed.searchParams.get("st"),
signupToken,
"saved URL keeps signup token parameter",
);

// Simulate page refresh destroying the in-memory flow handle.
originalFlow.free();

// 2) Signer approves while original flow is gone.
await signer.signup(HOMESERVER_PUBLICKEY, signupToken);
await signer.approveAuthRequest(savedUrl);

// 3) Resume from persisted URL.
const resumedFlow = sdk.resumeAuthFlow(savedUrl);

t.equal(
resumedFlow.authorizationUrl,
savedUrl,
"resumed signup flow produces the same authorization URL",
);

const resumedParsed = new URL(resumedFlow.authorizationUrl);
t.equal(
resumedParsed.searchParams.get("hs"),
HOMESERVER_PUBLICKEY.z32(),
"resumed URL keeps homeserver parameter",
);
t.equal(
resumedParsed.searchParams.get("st"),
signupToken,
"resumed URL keeps signup token parameter",
);

const session = await resumedFlow.awaitApproval();

t.equal(
session.info.publicKey.z32(),
pubky,
"resumed signup flow session belongs to expected user",
);
t.deepEqual(
session.info.capabilities,
capabilities.split(","),
"resumed signup flow session capabilities match",
);

t.end();
});

test("resumeAuthFlow: rejects invalid URL", async (t) => {
const sdk = Pubky.testnet();

try {
sdk.resumeAuthFlow("https://not-a-pubkyauth-url.com");
t.fail("resumeAuthFlow() should throw on non-pubkyauth URL");
} catch (error) {
assertPubkyError(t, error);
t.equal(error.name, "AuthenticationError", "invalid URL -> AuthenticationError");
t.ok(
/Failed to parse/i.test(error.message),
"error message explains parse failure",
);
}

try {
sdk.resumeAuthFlow("pubkyauth://secret_export?secret=kqnceEMgrNQM_xi06oQXjA3cJHX_RQmw1BY6JE1bse8");
t.fail("resumeAuthFlow() should reject seed export URLs");
} catch (error) {
assertPubkyError(t, error);
t.equal(error.name, "AuthenticationError", "seed export URL -> AuthenticationError");
t.ok(
/Only signin and signup/i.test(error.message),
"error message explains only auth URLs are valid",
);
}

t.end();
});

// Covers the pure string validator without running the flow.
// Ensures normalization behavior and precise error reporting.
test("validateCapabilities(): ok, normalize, and precise errors", async (t) => {
Expand Down
65 changes: 53 additions & 12 deletions pubky-sdk/bindings/js/src/actors/auth_flow.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use pubky::PubkyAuthFlow;
use pubky_common::capabilities::Capabilities;
use std::{cell::RefCell, rc::Rc};
use url::Url;
Expand All @@ -20,7 +21,7 @@ use crate::{
/// 3) `awaitApproval()` to receive a ready `Session`
#[wasm_bindgen]
pub struct AuthFlow {
inner: RefCell<Option<Rc<pubky::PubkyAuthFlow>>>,
inner: RefCell<Option<Rc<PubkyAuthFlow>>>,
in_flight: RefCell<bool>,
authorization_url: String,
}
Expand Down Expand Up @@ -80,7 +81,7 @@ impl AuthFlow {
let caps = Capabilities::try_from(normalized.as_str())?;

// 3) Build the flow with optional relay and optional client
let mut builder = pubky::PubkyAuthFlow::builder(&caps, kind.0);
let mut builder = PubkyAuthFlow::builder(&caps, kind.0);
if let Some(c) = client {
builder = builder.client(c);
}
Expand All @@ -90,21 +91,50 @@ impl AuthFlow {
}

let flow = builder.start()?;
let auth_url = flow.authorization_url().as_str().to_string();
Ok(flow.into())
}

Ok(AuthFlow {
authorization_url: auth_url,
in_flight: RefCell::new(false),
inner: RefCell::new(Some(Rc::new(flow))),
})
/// Resume a previously started auth flow from its saved `authorizationUrl` (standalone).
/// Prefer `pubky.resumeAuthFlow()` to reuse a facade client; this creates a default (mainnet) client.
///
/// Relay messages expire after ~5 minutes; resume is only viable in that window.
/// See `Pubky.resumeAuthFlow()` / `Pubky.startAuthFlow()` for full guidance.
///
/// **Security:** `authorizationUrl` contains the `client_secret`.
/// Delete it from storage as soon as resume completes or is abandoned.
///
/// @param {string} authorizationUrl The `pubkyauth://…` URL from a previous flow.
/// @returns {AuthFlow} A flow reconnected to the original relay channel.
/// @throws {PubkyError}
/// - `{ name: "AuthenticationError" }` if the URL is invalid or not a signin/signup link
#[wasm_bindgen(js_name = "resume")]
pub fn resume(authorization_url: String) -> JsResult<AuthFlow> {
Self::resume_with_client(authorization_url, None)
}

/// Internal helper that threads an explicit transport for resume.
pub(crate) fn resume_with_client(
authorization_url: String,
client: Option<pubky::PubkyHttpClient>,
) -> JsResult<AuthFlow> {
let pubky = match client {
Some(c) => pubky::Pubky::with_client(c),
None => pubky::Pubky::new()?,
};
let flow = pubky.resume_auth_flow(&authorization_url)?;
Ok(flow.into())
}

/// Return the authorization deep link (URL) to show as QR or open on the signer device.
///
/// **Security:** This URL contains the `client_secret` in plaintext.
/// Treat it as a short-lived secret and delete it after the flow completes.
/// See `Pubky.startAuthFlow()` docs for storage guidance.
///
/// @returns {string} A `pubkyauth://…` or `https://…` URL with channel info.
///
/// @example
/// renderQr(flow.authorizationUrl());
/// renderQr(flow.authorizationUrl);
#[wasm_bindgen(js_name = "authorizationUrl", getter)]
pub fn authorization_url(&self) -> String {
self.authorization_url.clone()
Expand Down Expand Up @@ -172,6 +202,17 @@ impl AuthFlow {
}
}

impl From<PubkyAuthFlow> for AuthFlow {
fn from(flow: PubkyAuthFlow) -> Self {
let auth_url = flow.authorization_url().as_str().to_string();
AuthFlow {
authorization_url: auth_url,
in_flight: RefCell::new(false),
inner: RefCell::new(Some(Rc::new(flow))),
}
}
}

impl AuthFlow {
fn begin_call(&self, caller: &str) -> JsResult<InFlightGuard<'_>> {
let mut flag = self.in_flight.borrow_mut();
Expand All @@ -185,22 +226,22 @@ impl AuthFlow {
}
}

fn borrow_inner(&self) -> JsResult<Rc<pubky::PubkyAuthFlow>> {
fn borrow_inner(&self) -> JsResult<Rc<PubkyAuthFlow>> {
self.inner
.borrow()
.as_ref()
.cloned()
.ok_or_else(|| self.consumed_error())
}

fn take_inner(&self, caller: &str) -> JsResult<Rc<pubky::PubkyAuthFlow>> {
fn take_inner(&self, caller: &str) -> JsResult<Rc<PubkyAuthFlow>> {
self.inner
.borrow_mut()
.take()
.ok_or_else(|| self.already_taken_error(caller))
}

fn restore_inner(&self, flow: Rc<pubky::PubkyAuthFlow>) {
fn restore_inner(&self, flow: Rc<PubkyAuthFlow>) {
let mut inner = self.inner.borrow_mut();
*inner = Some(flow);
}
Expand Down
23 changes: 23 additions & 0 deletions pubky-sdk/bindings/js/src/pubky.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ impl Pubky {
/// - `actions` is any combo of `r` and/or `w` (order normalized; `wr` -> `rw`).
/// Pass `""` for no scopes (read-only public session).
///
/// **Security:** `authorizationUrl` contains the `client_secret` in plaintext.
/// If you need resume after refresh/app switch, save it in `sessionStorage`
/// (not `localStorage`), then delete it once approval arrives or is abandoned.
///
/// @param {string} capabilities Comma-separated caps, e.g. `"/pub/app/:rw,/pub/foo/file:r"`.
/// @param {AuthFlowKind} kind The kind of authentication flow to perform.
/// Examples:
Expand Down Expand Up @@ -103,6 +107,25 @@ impl Pubky {
Ok(flow)
}

/// Resume a previously started **pubkyauth** flow from its saved `authorizationUrl`.
///
/// The relay inbox retains messages for **~5 minutes**. Resume is only
/// viable within that window; afterwards start a fresh flow.
///
/// **Security:** The URL contains the `client_secret` in plaintext.
/// Delete it from storage as soon as the resumed flow completes.
/// See `startAuthFlow()` docs for full storage guidance.
///
/// @param {string} authorizationUrl The `pubkyauth://…` URL from a previous flow.
/// @returns {AuthFlow} A flow reconnected to the original relay channel.
/// @throws {PubkyError}
/// - `{ name: "AuthenticationError" }` if the URL is invalid or not a signin/signup link
/// - `{ name: "RequestError" }` on network/relay failure
#[wasm_bindgen(js_name = "resumeAuthFlow")]
pub fn resume_auth_flow(&self, authorization_url: String) -> JsResult<AuthFlow> {
AuthFlow::resume_with_client(authorization_url, Some(self.0.client().clone()))
}

/// Create a `Signer` from an existing `Keypair`.
///
/// @param {Keypair} keypair The user’s keys.
Expand Down
Loading
Loading