From 6386f1ae4355d70c70abe652d9104ae4ec334cfa Mon Sep 17 00:00:00 2001 From: SHAcollision <127778313+SHAcollision@users.noreply.github.com> Date: Mon, 17 Mar 2025 15:58:58 -0400 Subject: [PATCH] feat: signup tokens (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add admin and signup config * Add signup tokens API, db, admin endpoint * Add client api for signup codes * Add tests and fixes * Fix wasm build * Lint * enable and use same admin pswd on all test homeservers * fix pr review comments * Add nodejs and browser signup token to tests * update signup example * admin authing as layer * Update pubky-homeserver/src/core/routes/auth.rs Co-authored-by: Severin Alexander Bühler <8782386+SeverinAlexB@users.noreply.github.com> * Update pubky-homeserver/src/core/routes/auth.rs Co-authored-by: Severin Alexander Bühler <8782386+SeverinAlexB@users.noreply.github.com> * rename getSignupToken util * add is_used() SignupToken method --------- Co-authored-by: Severin Alexander Bühler <8782386+SeverinAlexB@users.noreply.github.com> --- examples/authn/README.md | 5 +- examples/authn/signup.rs | 9 +- examples/authz/authenticator.rs | 2 +- pubky-homeserver/README.md | 33 ++++ pubky-homeserver/src/config.example.toml | 8 + pubky-homeserver/src/config.rs | 56 +++++-- .../src/core/database/migrations/m0.rs | 5 +- pubky-homeserver/src/core/database/tables.rs | 8 +- .../src/core/database/tables/entries.rs | 2 +- .../src/core/database/tables/signup_tokens.rs | 91 +++++++++++ .../src/core/database/tables/users.rs | 4 +- pubky-homeserver/src/core/layers/admin.rs | 87 +++++++++++ pubky-homeserver/src/core/layers/mod.rs | 1 + pubky-homeserver/src/core/mod.rs | 30 +++- pubky-homeserver/src/core/routes/admin.rs | 14 ++ pubky-homeserver/src/core/routes/auth.rs | 141 +++++++++++++---- pubky-homeserver/src/core/routes/mod.rs | 3 + pubky-homeserver/src/io/mod.rs | 28 +++- pubky-testnet/src/lib.rs | 9 +- pubky/README.md | 2 +- pubky/pkg/README.md | 93 ++++++++---- pubky/pkg/package.json | 2 +- pubky/pkg/test/auth.js | 19 ++- pubky/pkg/test/public.js | 25 ++- pubky/pkg/test/utils.js | 25 +++ pubky/src/native/api/auth.rs | 142 ++++++++++++++++-- pubky/src/native/api/public.rs | 49 ++++-- pubky/src/wasm/api/auth.rs | 7 +- 28 files changed, 771 insertions(+), 129 deletions(-) create mode 100644 pubky-homeserver/src/core/database/tables/signup_tokens.rs create mode 100644 pubky-homeserver/src/core/layers/admin.rs create mode 100644 pubky-homeserver/src/core/routes/admin.rs create mode 100644 pubky/pkg/test/utils.js diff --git a/examples/authn/README.md b/examples/authn/README.md index 162c19e..3119247 100644 --- a/examples/authn/README.md +++ b/examples/authn/README.md @@ -3,11 +3,10 @@ You can use these examples to test Signup or Signin to a provided homeserver using a keypair, as opposed to using a the 3rd party [authorization flow](../authz). - -## Usage +## Usage ### Signup ```bash -cargo run --bin signup +cargo run --bin signup ``` diff --git a/examples/authn/signup.rs b/examples/authn/signup.rs index 293d673..ea61d59 100644 --- a/examples/authn/signup.rs +++ b/examples/authn/signup.rs @@ -11,6 +11,9 @@ struct Cli { /// Path to a recovery_file of the Pubky you want to sign in with recovery_file: PathBuf, + + /// Signup code (optional) + signup_code: Option, } #[tokio::main] @@ -32,7 +35,11 @@ async fn main() -> Result<()> { println!("Successfully decrypted the recovery file, signing up to the homeserver:"); client - .signup(&keypair, &PublicKey::try_from(homeserver).unwrap()) + .signup( + &keypair, + &PublicKey::try_from(homeserver).unwrap(), + cli.signup_code.as_deref(), + ) .await?; println!("Successfully signed up. Checking session:"); diff --git a/examples/authz/authenticator.rs b/examples/authz/authenticator.rs index 284b782..f28f826 100644 --- a/examples/authz/authenticator.rs +++ b/examples/authz/authenticator.rs @@ -74,7 +74,7 @@ async fn main() -> Result<()> { // the user has an account on the local homeserver. if client.signin(&keypair).await.is_err() { client - .signup(&keypair, &PublicKey::try_from(HOMESERVER).unwrap()) + .signup(&keypair, &PublicKey::try_from(HOMESERVER).unwrap(), None) .await?; }; diff --git a/pubky-homeserver/README.md b/pubky-homeserver/README.md index 93b8c28..76e2850 100644 --- a/pubky-homeserver/README.md +++ b/pubky-homeserver/README.md @@ -24,6 +24,39 @@ async fn main() { } ``` +If homeserver is set to require signup tokens, you can create a new signup token using the admin endpoint: + +```rust,ignore +let response = pubky_client + .get(&format!("https://{homeserver_pubkey}/admin/generate_signup_token")) + .header("X-Admin-Password", "admin") // Use your admin password. This is testnet default pwd. + .send() + .await + .unwrap(); +let signup_token = response.text().await.unwrap(); +``` + +via CLI with `curl` + +```bash +curl -X GET "https:///admin/generate_signup_token" \ + -H "X-Admin-Password: admin" + # Use your admin password. This is testnet default pwd. +``` + +or from JS + +```js +const url = "http://${homeserver_address}/admin/generate_signup_token"; +const response = await client.fetch(url, { + method: "GET", + headers: { + "X-Admin-Password": "admin", // use your admin password, defaults to testnet password. + }, +}); +const signupToken = await response.text(); +``` + ### Binary Use `cargo run` diff --git a/pubky-homeserver/src/config.example.toml b/pubky-homeserver/src/config.example.toml index 5a4ee5c..069bd95 100644 --- a/pubky-homeserver/src/config.example.toml +++ b/pubky-homeserver/src/config.example.toml @@ -1,6 +1,14 @@ # Secret key (in hex) to generate the Homeserver's Keypair # secret_key = "0000000000000000000000000000000000000000000000000000000000000000" +[admin] +# Set an admin password to protect admin endpoints. +# If no password is set, the admin endpoints will not be accessible. +password = "admin" +# Set signup_mode to "open" to allow anyone to signup a new user, +# otherwise, "token_required" (the default) to require a valid invite token on signup; +signup_mode = "token_required" + [database] # Storage directory Defaults to # diff --git a/pubky-homeserver/src/config.rs b/pubky-homeserver/src/config.rs index b2a82ed..c526100 100644 --- a/pubky-homeserver/src/config.rs +++ b/pubky-homeserver/src/config.rs @@ -10,7 +10,10 @@ use std::{ path::{Path, PathBuf}, }; -use crate::{core::CoreConfig, io::IoConfig}; +use crate::{ + core::{AdminConfig, CoreConfig, SignupMode}, + io::IoConfig, +}; // === Core == pub const DEFAULT_STORAGE_DIR: &str = "pubky"; @@ -38,6 +41,13 @@ struct LegacyBrowsersTompl { pub domain: Option, } +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +struct AdminToml { + pub password: Option, + /// "open" or "token_required" (defaults to "token_required", i.e., a signup token is required) + pub signup_mode: Option, +} + #[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] struct IoToml { pub http_port: Option, @@ -51,9 +61,9 @@ struct IoToml { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] struct ConfigToml { secret_key: Option, - database: Option, io: Option, + admin: Option, } /// Server configuration @@ -63,9 +73,20 @@ pub struct Config { /// /// Defaults to a random keypair. pub keypair: Keypair, - pub io: IoConfig, pub core: CoreConfig, + pub admin: AdminConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + keypair: Keypair::random(), + io: IoConfig::default(), + core: CoreConfig::default(), + admin: AdminConfig::default(), + } + } } impl Config { @@ -112,21 +133,12 @@ impl Config { ..Default::default() }, core: CoreConfig::test(), + admin: AdminConfig::test(), ..Default::default() } } } -impl Default for Config { - fn default() -> Self { - Self { - keypair: Keypair::random(), - io: IoConfig::default(), - core: CoreConfig::default(), - } - } -} - impl TryFrom for Config { type Error = anyhow::Error; @@ -175,14 +187,26 @@ impl TryFrom for Config { } }; + let admin = if let Some(admin_toml) = value.admin { + AdminConfig { + password: admin_toml.password.clone(), + signup_mode: match admin_toml.signup_mode.as_deref() { + Some("open") => SignupMode::Open, + _ => SignupMode::TokenRequired, + }, + } + } else { + AdminConfig::default() + }; + Ok(Config { keypair, - io, core: CoreConfig { storage, ..Default::default() }, + admin, }) } } @@ -258,6 +282,10 @@ mod tests { ..Default::default() }, + admin: AdminConfig { + password: Some("admin".to_string()), + signup_mode: SignupMode::Open + } } ) } diff --git a/pubky-homeserver/src/core/database/migrations/m0.rs b/pubky-homeserver/src/core/database/migrations/m0.rs index 27f4b9e..480ba03 100644 --- a/pubky-homeserver/src/core/database/migrations/m0.rs +++ b/pubky-homeserver/src/core/database/migrations/m0.rs @@ -1,6 +1,6 @@ use heed::{Env, RwTxn}; -use crate::core::database::tables::{blobs, entries, events, sessions, users}; +use crate::core::database::tables::{blobs, entries, events, sessions, signup_tokens, users}; pub fn run(env: &Env, wtxn: &mut RwTxn) -> anyhow::Result<()> { let _: users::UsersTable = env.create_database(wtxn, Some(users::USERS_TABLE))?; @@ -13,5 +13,8 @@ pub fn run(env: &Env, wtxn: &mut RwTxn) -> anyhow::Result<()> { let _: events::EventsTable = env.create_database(wtxn, Some(events::EVENTS_TABLE))?; + let _: signup_tokens::SignupTokensTable = + env.create_database(wtxn, Some(signup_tokens::SIGNUP_TOKENS_TABLE))?; + Ok(()) } diff --git a/pubky-homeserver/src/core/database/tables.rs b/pubky-homeserver/src/core/database/tables.rs index e879bd0..0189c12 100644 --- a/pubky-homeserver/src/core/database/tables.rs +++ b/pubky-homeserver/src/core/database/tables.rs @@ -2,6 +2,7 @@ pub mod blobs; pub mod entries; pub mod events; pub mod sessions; +pub mod signup_tokens; pub mod users; use heed::{Env, RwTxn}; @@ -12,10 +13,11 @@ use entries::{EntriesTable, ENTRIES_TABLE}; use self::{ events::{EventsTable, EVENTS_TABLE}, sessions::{SessionsTable, SESSIONS_TABLE}, + signup_tokens::{SignupTokensTable, SIGNUP_TOKENS_TABLE}, users::{UsersTable, USERS_TABLE}, }; -pub const TABLES_COUNT: u32 = 5; +pub const TABLES_COUNT: u32 = 6; #[derive(Debug, Clone)] pub struct Tables { @@ -24,6 +26,7 @@ pub struct Tables { pub blobs: BlobsTable, pub entries: EntriesTable, pub events: EventsTable, + pub signup_tokens: SignupTokensTable, } impl Tables { @@ -44,6 +47,9 @@ impl Tables { events: env .open_database(wtxn, Some(EVENTS_TABLE))? .expect("Events table already created"), + signup_tokens: env + .open_database(wtxn, Some(SIGNUP_TOKENS_TABLE))? + .expect("Signup tokens table already created"), }) } } diff --git a/pubky-homeserver/src/core/database/tables/entries.rs b/pubky-homeserver/src/core/database/tables/entries.rs index 547f496..8870a5b 100644 --- a/pubky-homeserver/src/core/database/tables/entries.rs +++ b/pubky-homeserver/src/core/database/tables/entries.rs @@ -431,7 +431,7 @@ impl<'db> EntryWriter<'db> { } } -impl<'db> std::io::Write for EntryWriter<'db> { +impl std::io::Write for EntryWriter<'_> { /// Write a chunk to a Filesystem based buffer. #[inline] fn write(&mut self, chunk: &[u8]) -> std::io::Result { diff --git a/pubky-homeserver/src/core/database/tables/signup_tokens.rs b/pubky-homeserver/src/core/database/tables/signup_tokens.rs new file mode 100644 index 0000000..30bffc3 --- /dev/null +++ b/pubky-homeserver/src/core/database/tables/signup_tokens.rs @@ -0,0 +1,91 @@ +use crate::core::database::DB; +use base32::{encode, Alphabet}; +use heed::{ + types::{Bytes, Str}, + Database, +}; +use pkarr::PublicKey; +use postcard::{from_bytes, to_allocvec}; +use pubky_common::{crypto::random_bytes, timestamp::Timestamp}; +use serde::{Deserialize, Serialize}; + +pub const SIGNUP_TOKENS_TABLE: &str = "signup_tokens"; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SignupToken { + pub token: String, + pub created_at: u64, + /// If Some(pubkey), the token has been used. + pub used: Option, +} + +impl SignupToken { + pub fn serialize(&self) -> Vec { + to_allocvec(self).expect("serialize signup token") + } + + pub fn deserialize(bytes: &[u8]) -> Self { + from_bytes(bytes).expect("deserialize signup token") + } + + pub fn is_used(&self) -> bool { + self.used.is_some() + } + + // Generate 7 random bytes and encode as BASE32, fully uppercase + // with hyphens every 4 characters. Example, `QXV0-15V7-EXY0` + pub fn random() -> Self { + let bytes = random_bytes::<7>(); + let encoded = encode(Alphabet::Crockford, &bytes).to_uppercase(); + let mut with_hyphens = String::new(); + for (i, ch) in encoded.chars().enumerate() { + if i > 0 && i % 4 == 0 { + with_hyphens.push('-'); + } + with_hyphens.push(ch); + } + + SignupToken { + token: with_hyphens, + created_at: Timestamp::now().as_u64(), + used: None, + } + } +} + +impl DB { + pub fn generate_signup_token(&mut self) -> anyhow::Result { + let signup_token = SignupToken::random(); + let mut wtxn = self.env.write_txn()?; + self.tables + .signup_tokens + .put(&mut wtxn, &signup_token.token, &signup_token.serialize())?; + wtxn.commit()?; + Ok(signup_token.token) + } + + pub fn validate_and_consume_signup_token( + &self, + token: &str, + user_pubkey: &PublicKey, + ) -> anyhow::Result<()> { + let mut wtxn = self.env.write_txn()?; + if let Some(token_bytes) = self.tables.signup_tokens.get(&wtxn, token)? { + let mut signup_token = SignupToken::deserialize(token_bytes); + if signup_token.is_used() { + anyhow::bail!("Token already used"); + } + // Mark token as used. + signup_token.used = Some(user_pubkey.clone()); + self.tables + .signup_tokens + .put(&mut wtxn, token, &signup_token.serialize())?; + wtxn.commit()?; + Ok(()) + } else { + anyhow::bail!("Invalid token"); + } + } +} + +pub type SignupTokensTable = Database; diff --git a/pubky-homeserver/src/core/database/tables/users.rs b/pubky-homeserver/src/core/database/tables/users.rs index cf9b44e..6834c84 100644 --- a/pubky-homeserver/src/core/database/tables/users.rs +++ b/pubky-homeserver/src/core/database/tables/users.rs @@ -19,7 +19,7 @@ pub struct User { pub created_at: u64, } -impl<'a> BytesEncode<'a> for User { +impl BytesEncode<'_> for User { type EItem = Self; fn bytes_encode(user: &Self::EItem) -> Result, BoxedError> { @@ -41,7 +41,7 @@ impl<'a> BytesDecode<'a> for User { pub struct PublicKeyCodec {} -impl<'a> BytesEncode<'a> for PublicKeyCodec { +impl BytesEncode<'_> for PublicKeyCodec { type EItem = PublicKey; fn bytes_encode(pubky: &Self::EItem) -> Result, BoxedError> { diff --git a/pubky-homeserver/src/core/layers/admin.rs b/pubky-homeserver/src/core/layers/admin.rs new file mode 100644 index 0000000..7fb287f --- /dev/null +++ b/pubky-homeserver/src/core/layers/admin.rs @@ -0,0 +1,87 @@ +// src/core/layers/admin_auth.rs +use axum::{ + body::Body, + http::{Request, StatusCode}, + response::Response, +}; +use futures_util::future::BoxFuture; +use std::{convert::Infallible, task::Poll}; +use tower::{Layer, Service}; + +/// A Tower Layer that checks the “X-Admin-Password” header against a configured password. +#[derive(Clone)] +pub struct AdminAuthLayer { + password: String, +} + +impl AdminAuthLayer { + /// Create a new AdminAuthLayer with the given admin password. + pub fn new(password: String) -> Self { + Self { password } + } +} + +impl Layer for AdminAuthLayer { + type Service = AdminAuthMiddleware; + + fn layer(&self, inner: S) -> Self::Service { + AdminAuthMiddleware { + inner, + password: self.password.clone(), + } + } +} + +/// Middleware that performs the admin password check. +#[derive(Clone)] +pub struct AdminAuthMiddleware { + inner: S, + password: String, +} + +impl Service> for AdminAuthMiddleware +where + S: Service, Response = Response, Error = Infallible> + Clone + Send + 'static, + S::Future: Send + 'static, + ReqBody: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let password = self.password.clone(); + let mut inner = self.inner.clone(); + + Box::pin(async move { + match req.headers().get("X-Admin-Password") { + Some(header_value) if header_value.to_str().unwrap_or("") == password => { + // If the header is valid, proceed. + inner.call(req).await + } + Some(_) => { + // If header exists but password is incorrect, + let msg = "Invalid admin password"; + let response = Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::from(msg)) + .unwrap_or_else(|_| Response::new(Body::from(msg))); + Ok(response) + } + None => { + // If header is missing, do the same. + let msg = "Missing admin password"; + let response = Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::from(msg)) + .unwrap_or_else(|_| Response::new(Body::from(msg))); + Ok(response) + } + } + }) + } +} diff --git a/pubky-homeserver/src/core/layers/mod.rs b/pubky-homeserver/src/core/layers/mod.rs index cceacd2..d8fbe7d 100644 --- a/pubky-homeserver/src/core/layers/mod.rs +++ b/pubky-homeserver/src/core/layers/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod authz; pub mod pubky_host; pub mod trace; diff --git a/pubky-homeserver/src/core/mod.rs b/pubky-homeserver/src/core/mod.rs index ac3b119..dafbfa9 100644 --- a/pubky-homeserver/src/core/mod.rs +++ b/pubky-homeserver/src/core/mod.rs @@ -20,6 +20,7 @@ use database::DB; pub(crate) struct AppState { pub(crate) verifier: AuthVerifier, pub(crate) db: DB, + pub(crate) admin: AdminConfig, } #[derive(Debug, Clone)] @@ -34,12 +35,13 @@ impl HomeserverCore { /// # Safety /// HomeserverCore uses LMDB, [opening][heed::EnvOpenOptions::open] which is marked unsafe, /// because the possible Undefined Behavior (UB) if the lock file is broken. - pub unsafe fn new(config: CoreConfig) -> Result { + pub unsafe fn new(config: CoreConfig, admin: AdminConfig) -> Result { let db = unsafe { DB::open(config.clone())? }; let state = AppState { verifier: AuthVerifier::default(), db, + admin, }; let router = routes::create_app(state.clone()); @@ -67,7 +69,7 @@ mod tests { impl HomeserverCore { /// Test version of [HomeserverCore::new], using an ephemeral small storage. pub fn test() -> Result { - unsafe { HomeserverCore::new(CoreConfig::test()) } + unsafe { HomeserverCore::new(CoreConfig::test(), AdminConfig::test()) } } // === Public Methods === @@ -102,6 +104,30 @@ mod tests { } } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum SignupMode { + Open, + #[default] + TokenRequired, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AdminConfig { + /// The password used to authorize admin endpoints. + pub password: Option, + /// Determines whether new signups require a valid token. + pub signup_mode: SignupMode, +} + +impl AdminConfig { + pub fn test() -> Self { + AdminConfig { + password: Some("admin".to_string()), + signup_mode: SignupMode::Open, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] /// Database configurations pub struct CoreConfig { diff --git a/pubky-homeserver/src/core/routes/admin.rs b/pubky-homeserver/src/core/routes/admin.rs new file mode 100644 index 0000000..945b10f --- /dev/null +++ b/pubky-homeserver/src/core/routes/admin.rs @@ -0,0 +1,14 @@ +use crate::core::{error::Result, layers::admin::AdminAuthLayer, AppState}; +use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Router}; + +pub async fn generate_signup_token(State(mut state): State) -> Result { + let token = state.db.generate_signup_token()?; + Ok((StatusCode::OK, token)) +} + +pub fn router(state: AppState) -> Router { + let admin_password = state.admin.password.unwrap_or_default(); + Router::new() + .route("/generate_signup_token", get(generate_signup_token)) + .layer(AdminAuthLayer::new(admin_password)) +} diff --git a/pubky-homeserver/src/core/routes/auth.rs b/pubky-homeserver/src/core/routes/auth.rs index 21857fb..aefc42e 100644 --- a/pubky-homeserver/src/core/routes/auth.rs +++ b/pubky-homeserver/src/core/routes/auth.rs @@ -1,24 +1,85 @@ -use axum::{extract::State, response::IntoResponse}; +use crate::core::{ + database::tables::users::User, + error::{Error, Result}, + AppState, SignupMode, +}; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, +}; use axum_extra::{extract::Host, headers::UserAgent, TypedHeader}; +use base32::{encode, Alphabet}; use bytes::Bytes; +use pkarr::PublicKey; +use pubky_common::{ + capabilities::Capability, crypto::random_bytes, session::Session, timestamp::Timestamp, +}; +use std::collections::HashMap; use tower_cookies::{cookie::SameSite, Cookie, Cookies}; -use pubky_common::{crypto::random_bytes, session::Session, timestamp::Timestamp}; - -use crate::core::{database::tables::users::User, error::Result, AppState}; - +/// Creates a brand-new user if they do not exist, then logs them in by creating a session. +/// 1) Check if signup tokens are required (signup mode is token_required). +/// 2) Ensure the user *does not* already exist. +/// 3) Create new user if needed. +/// 4) Create a session and set the cookie (using the shared helper). pub async fn signup( State(state): State, user_agent: Option>, cookies: Cookies, - host: Host, + Host(host): Host, + Query(params): Query>, // for extracting `signup_token` if needed body: Bytes, ) -> Result { - // TODO: Verify invitation link. - // TODO: add errors in case of already axisting user. - signin(State(state), user_agent, cookies, host, body).await + // 1) Verify AuthToken from request body + let token = state.verifier.verify(&body)?; + let public_key = token.pubky(); + + // 2) Ensure the user does *not* already exist + let txn = state.db.env.read_txn()?; + let users = state.db.tables.users; + if users.get(&txn, public_key)?.is_some() { + return Err(Error::new( + StatusCode::CONFLICT, + Some("User already exists"), + )); + } + txn.commit()?; + + // 3) If signup_mode == token_required, require & validate a `signup_token` param. + if state.admin.signup_mode == SignupMode::TokenRequired { + let signup_token_param = params + .get("signup_token") + .ok_or_else(|| Error::new(StatusCode::BAD_REQUEST, Some("signup_token required")))?; + // Validate it in the DB (marks it used) + state + .db + .validate_and_consume_signup_token(signup_token_param, public_key)?; + } + + // 4) Create the new user record + let mut wtxn = state.db.env.write_txn()?; + users.put( + &mut wtxn, + public_key, + &User { + created_at: Timestamp::now().as_u64(), + }, + )?; + wtxn.commit()?; + + // 5) Create session & set cookie + create_session_and_cookie( + &state, + cookies, + &host, + public_key, + token.capabilities(), + user_agent, + ) } +/// Fails if user doesn’t exist, otherwise logs them in by creating a session. pub async fn signin( State(state): State, user_agent: Option>, @@ -26,54 +87,68 @@ pub async fn signin( Host(host): Host, body: Bytes, ) -> Result { + // 1) Verify the AuthToken in the request body let token = state.verifier.verify(&body)?; - let public_key = token.pubky(); - let mut wtxn = state.db.env.write_txn()?; - + // 2) Ensure user *does* exist + let txn = state.db.env.read_txn()?; let users = state.db.tables.users; - if let Some(existing) = users.get(&wtxn, public_key)? { - // TODO: why do we need this? - users.put(&mut wtxn, public_key, &existing)?; - } else { - users.put( - &mut wtxn, - public_key, - &User { - created_at: Timestamp::now().as_u64(), - }, - )?; + let user_exists = users.get(&txn, public_key)?.is_some(); + txn.commit()?; + if !user_exists { + return Err(Error::new( + StatusCode::NOT_FOUND, + Some("User does not exist"), + )); } - let session_secret = base32::encode(base32::Alphabet::Crockford, &random_bytes::<16>()); - - let session = Session::new( - token.pubky(), + // 3) Create the session & set cookie + create_session_and_cookie( + &state, + cookies, + &host, + public_key, token.capabilities(), + user_agent, + ) +} + +/// Creates and stores a session, sets the cookie, returns session as JSON/string. +fn create_session_and_cookie( + state: &AppState, + cookies: Cookies, + host: &str, + public_key: &PublicKey, + capabilities: &[Capability], + user_agent: Option>, +) -> Result { + // 1) Create session + let session_secret = encode(Alphabet::Crockford, &random_bytes::<16>()); + let session = Session::new( + public_key, + capabilities, user_agent.map(|ua| ua.to_string()), ) .serialize(); + // 2) Insert session into DB + let mut wtxn = state.db.env.write_txn()?; state .db .tables .sessions .put(&mut wtxn, &session_secret, &session)?; - wtxn.commit()?; + // 3) Build and set cookie let mut cookie = Cookie::new(public_key.to_string(), session_secret); - cookie.set_path("/"); - - // TODO: do we even have insecure anymore? - if is_secure(&host) { + if is_secure(host) { cookie.set_secure(true); cookie.set_same_site(SameSite::None); } cookie.set_http_only(true); - cookies.add(cookie); Ok(session) diff --git a/pubky-homeserver/src/core/routes/mod.rs b/pubky-homeserver/src/core/routes/mod.rs index a4fe82b..123f8cd 100644 --- a/pubky-homeserver/src/core/routes/mod.rs +++ b/pubky-homeserver/src/core/routes/mod.rs @@ -17,6 +17,7 @@ use crate::core::AppState; use super::layers::{pubky_host::PubkyHostLayer, trace::with_trace_layer}; +mod admin; mod auth; mod feed; mod root; @@ -40,11 +41,13 @@ fn base() -> Router { pub fn create_app(state: AppState) -> Router { let app = base() .merge(tenants::router(state.clone())) + .nest("/admin", admin::router(state.clone())) .layer(CookieManagerLayer::new()) .layer(CorsLayer::very_permissive()) .layer(ServiceBuilder::new().layer(middleware::from_fn(add_server_header))) .with_state(state); + // Apply trace and pubky host layers to the complete router. with_trace_layer(app, &TRACING_EXCLUDED_PATHS).layer(PubkyHostLayer) } diff --git a/pubky-homeserver/src/io/mod.rs b/pubky-homeserver/src/io/mod.rs index 51f3352..1588dfd 100644 --- a/pubky-homeserver/src/io/mod.rs +++ b/pubky-homeserver/src/io/mod.rs @@ -12,7 +12,7 @@ use tracing::info; use crate::{ config::{Config, DEFAULT_HTTPS_PORT, DEFAULT_HTTP_PORT}, - core::HomeserverCore, + core::{HomeserverCore, SignupMode}, }; mod http; @@ -58,6 +58,21 @@ impl HomeserverBuilder { self } + /// Set the signup mode to "token_required". Only to be used on ::test() + /// homeserver for the specific case of testing signup token flow. + pub fn close_signups(&mut self) -> &mut Self { + self.0.admin.signup_mode = SignupMode::TokenRequired; + + self + } + + /// Set a password to protect admin endpoints + pub fn admin_password(&mut self, password: String) -> &mut Self { + self.0.admin.password = Some(password); + + self + } + /// Run a Homeserver /// /// # Safety @@ -97,6 +112,15 @@ impl Homeserver { unsafe { Self::run(config) }.await } + /// Run a Homeserver with configurations suitable for ephemeral tests. + /// That requires signup tokens. + pub async fn run_test_with_signup_tokens(bootstrap: &[String]) -> Result { + let mut config = Config::test(bootstrap); + config.admin.signup_mode = SignupMode::TokenRequired; + + unsafe { Self::run(config) }.await + } + /// Run a Homeserver /// /// # Safety @@ -107,7 +131,7 @@ impl Homeserver { let keypair = config.keypair; - let core = unsafe { HomeserverCore::new(config.core)? }; + let core = unsafe { HomeserverCore::new(config.core, config.admin)? }; let http_servers = HttpServers::run(&keypair, &config.io, &core.router).await?; diff --git a/pubky-testnet/src/lib.rs b/pubky-testnet/src/lib.rs index 3844241..7a9b75b 100644 --- a/pubky-testnet/src/lib.rs +++ b/pubky-testnet/src/lib.rs @@ -75,7 +75,9 @@ impl Testnet { .storage(storage) .bootstrap(&dht.bootstrap) .relays(&[relay.local_url()]) - .domain("localhost"); + .domain("localhost") + .close_signups() + .admin_password("admin".to_string()); unsafe { builder.run().await }?; HttpRelay::builder().http_port(15412).run().await?; @@ -107,6 +109,11 @@ impl Testnet { Homeserver::run_test(&self.dht.bootstrap).await } + /// Run a Pubky Homeserver that requires signup tokens + pub async fn run_homeserver_with_signup_tokens(&self) -> Result { + Homeserver::run_test_with_signup_tokens(&self.dht.bootstrap).await + } + /// Run an HTTP Relay pub async fn run_http_relay(&self) -> Result { HttpRelay::builder().run().await diff --git a/pubky/README.md b/pubky/README.md index 951d2c2..8ed06c9 100644 --- a/pubky/README.md +++ b/pubky/README.md @@ -24,7 +24,7 @@ async fn main () { // Signup to a Homeserver let keypair = Keypair::random(); - client.signup(&keypair, &homeserver.public_key()).await.unwrap(); + client.signup(&keypair, &homeserver.public_key(), None).await.unwrap(); // Write data. let url = format!("pubky://{}/pub/foo.txt", keypair.public_key()); diff --git a/pubky/pkg/README.md b/pubky/pkg/README.md index 5a61a04..75cfb4c 100644 --- a/pubky/pkg/README.md +++ b/pubky/pkg/README.md @@ -3,6 +3,7 @@ JavaScript implementation of [Pubky](https://github.com/pubky/pubky-core) client. ## Table of Contents + - [Install](#install) - [Getting Started](#getting-started) - [API](#api) @@ -21,7 +22,7 @@ For Nodejs, you need Node v20 or later. ## Getting started ```js -import { Client, Keypair, PublicKey } from '../index.js' +import { Client, Keypair, PublicKey } from "../index.js"; // Initialize Client with Pkarr relay(s). let client = new Client(); @@ -30,9 +31,11 @@ let client = new Client(); let keypair = Keypair.random(); // Create a new account -let homeserver = PublicKey.from("8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo"); +let homeserver = PublicKey.from( + "8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo" +); -await client.signup(keypair, homeserver) +await client.signup(keypair, homeserver, signup_token); const publicKey = keypair.publicKey(); @@ -40,24 +43,24 @@ const publicKey = keypair.publicKey(); let url = `pubky://${publicKey.z32()}/pub/example.com/arbitrary`; // Verify that you are signed in. -const session = await client.session(publicKey) +const session = await client.session(publicKey); // PUT public data, by authorized client -await client.fetch(url, { - method: "PUT", - body: JSON.stringify({foo: "bar"}), - credentials: "include" +await client.fetch(url, { + method: "PUT", + body: JSON.stringify({ foo: "bar" }), + credentials: "include", }); // GET public data without signup or signin { - const client = new Client(); + const client = new Client(); - let response = await client.fetch(url); + let response = await client.fetch(url); } // Delete public data, by authorized client -await client.fetch(url, { method: "DELETE", credentials: "include "}); +await client.fetch(url, { method: "DELETE", credentials: "include " }); ``` ## API @@ -65,11 +68,13 @@ await client.fetch(url, { method: "DELETE", credentials: "include "}); ### Client #### constructor + ```js -let client = new Client() +let client = new Client(); ``` #### fetch + ```js let response = await client.fetch(url, opts); ``` @@ -77,35 +82,45 @@ let response = await client.fetch(url, opts); Just like normal Fetch API, but it can handle `pubky://` urls and `http(s)://` urls with Pkarr domains. #### signup + ```js -await client.signup(keypair, homeserver) +await client.signup(keypair, homeserver, signup_token); ``` + - keypair: An instance of [Keypair](#keypair). - homeserver: An instance of [PublicKey](#publickey) representing the homeserver. +- signup_token: A homeserver could optionally ask for a valid signup token (aka, invitation code). Returns: + - session: An instance of [Session](#session). #### signin + ```js -let session = await client.signin(keypair) +let session = await client.signin(keypair); ``` + - keypair: An instance of [Keypair](#keypair). Returns: + - An instance of [Session](#session). #### signout + ```js -await client.signout(publicKey) +await client.signout(publicKey); ``` + - publicKey: An instance of [PublicKey](#publicKey). #### authRequest + ```js let pubkyAuthRequest = client.authRequest(relay, capabilities); -let pubkyauthUrl= pubkyAuthRequest.url(); +let pubkyauthUrl = pubkyAuthRequest.url(); showQr(pubkyauthUrl); @@ -119,25 +134,31 @@ instead request permissions (showing the user pubkyauthUrl), and await a Session - capabilities: A list of capabilities required for the app for example `/pub/pubky.app/:rw,/pub/example.com/:r`. #### sendAuthToken + ```js await client.sendAuthToken(keypair, pubkyauthUrl); ``` + Consenting to authentication or authorization according to the required capabilities in the `pubkyauthUrl` , and sign and send an auth token to the requester. - keypair: An instance of [KeyPair](#keypair) - pubkyauthUrl: A string `pubkyauth://` url #### session {#session-method} + ```js -let session = await client.session(publicKey) +let session = await client.session(publicKey); ``` + - publicKey: An instance of [PublicKey](#publickey). - Returns: A [Session](#session) object if signed in, or undefined if not. ### list + ```js -let response = await client.list(url, cursor, reverse, limit) +let response = await client.list(url, cursor, reverse, limit); ``` + - url: A string representing the Pubky URL. The path in that url is the prefix that you want to list files within. - cursor: Usually the last URL from previous calls. List urls after/before (depending on `reverse`) the cursor. - reverse: Whether or not return urls in reverse order. @@ -147,29 +168,36 @@ let response = await client.list(url, cursor, reverse, limit) ### Keypair #### random + ```js -let keypair = Keypair.random() +let keypair = Keypair.random(); ``` + - Returns: A new random Keypair. #### fromSecretKey + ```js -let keypair = Keypair.fromSecretKey(secretKey) +let keypair = Keypair.fromSecretKey(secretKey); ``` + - secretKey: A 32 bytes Uint8array. - Returns: A new Keypair. - #### publicKey {#publickey-method} + ```js -let publicKey = keypair.publicKey() +let publicKey = keypair.publicKey(); ``` + - Returns: The [PublicKey](#publickey) associated with the Keypair. #### secretKey + ```js -let secretKey = keypair.secretKey() +let secretKey = keypair.secretKey(); ``` + - Returns: The Uint8array secret key associated with the Keypair. ### PublicKey @@ -179,43 +207,54 @@ let secretKey = keypair.secretKey() ```js let publicKey = PublicKey.from(string); ``` + - string: A string representing the public key. - Returns: A new PublicKey instance. #### z32 + ```js let pubky = publicKey.z32(); ``` + Returns: The z-base-32 encoded string representation of the PublicKey. -### Session +### Session #### pubky + ```js let pubky = session.pubky(); ``` + Returns an instance of [PublicKey](#publickey) #### capabilities + ```js let capabilities = session.capabilities(); ``` + Returns an array of capabilities, for example `["/pub/pubky.app/:rw"]` ### Helper functions #### createRecoveryFile + ```js -let recoveryFile = createRecoveryFile(keypair, passphrase) +let recoveryFile = createRecoveryFile(keypair, passphrase); ``` + - keypair: An instance of [Keypair](#keypair). - passphrase: A utf-8 string [passphrase](https://www.useapassphrase.com/). - Returns: A recovery file with a spec line and an encrypted secret key. #### createRecoveryFile + ```js -let keypair = decryptRecoveryfile(recoveryFile, passphrase) +let keypair = decryptRecoveryfile(recoveryFile, passphrase); ``` + - recoveryFile: An instance of Uint8Array containing the recovery file blob. - passphrase: A utf-8 string [passphrase](https://www.useapassphrase.com/). - Returns: An instance of [Keypair](#keypair). @@ -246,7 +285,7 @@ npm run testnet Use the logged addresses as inputs to `Client` ```js -import { Client } from '../index.js' +import { Client } from "../index.js"; const client = Client().testnet(); ``` diff --git a/pubky/pkg/package.json b/pubky/pkg/package.json index 4820b05..e59e327 100644 --- a/pubky/pkg/package.json +++ b/pubky/pkg/package.json @@ -2,7 +2,7 @@ "name": "@synonymdev/pubky", "type": "module", "description": "Pubky client", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "repository": { "type": "git", diff --git a/pubky/pkg/test/auth.js b/pubky/pkg/test/auth.js index aed0e94..87cea3f 100644 --- a/pubky/pkg/test/auth.js +++ b/pubky/pkg/test/auth.js @@ -1,6 +1,7 @@ import test from 'tape' import { Client, Keypair, PublicKey, setLogLevel } from '../index.cjs' +import { createSignupToken } from './utils.js'; const HOMESERVER_PUBLICKEY = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo') const TESTNET_HTTP_RELAY = "http://localhost:15412/link"; @@ -11,7 +12,10 @@ test('Auth: basic', async (t) => { const keypair = Keypair.random() const publicKey = keypair.publicKey() - await client.signup(keypair, HOMESERVER_PUBLICKEY ) + const signupToken = await createSignupToken(client) + + // Use the received token to sign up. + await client.signup(keypair, HOMESERVER_PUBLICKEY, signupToken) const session = await client.session(publicKey) t.ok(session, "signup") @@ -20,7 +24,7 @@ test('Auth: basic', async (t) => { await client.signout(publicKey) const session = await client.session(publicKey) - t.notOk(session, "singout") + t.notOk(session, "signout") } { @@ -37,13 +41,16 @@ test("Auth: multi-user (cookies)", async (t) => { const alice = Keypair.random() const bob = Keypair.random() - await client.signup(alice, HOMESERVER_PUBLICKEY ) + const aliceSignupToken = await createSignupToken(client) + const bobSignupToken = await createSignupToken(client) + + await client.signup(alice, HOMESERVER_PUBLICKEY , aliceSignupToken) let session = await client.session(alice.publicKey()) t.ok(session, "signup") { - await client.signup(bob, HOMESERVER_PUBLICKEY ) + await client.signup(bob, HOMESERVER_PUBLICKEY, bobSignupToken) const session = await client.session(bob.publicKey()) t.ok(session, "signup") @@ -82,7 +89,9 @@ test("Auth: 3rd party signin", async (t) => { { let client = Client.testnet(); - await client.signup(keypair, HOMESERVER_PUBLICKEY); + const signupToken = await createSignupToken(client) + + await client.signup(keypair, HOMESERVER_PUBLICKEY, signupToken); await client.sendAuthToken(keypair, pubkyauthUrl) } diff --git a/pubky/pkg/test/public.js b/pubky/pkg/test/public.js index e123afb..6c61fa9 100644 --- a/pubky/pkg/test/public.js +++ b/pubky/pkg/test/public.js @@ -1,6 +1,7 @@ import test from 'tape' import { Client, Keypair, PublicKey, setLogLevel } from '../index.cjs' +import { createSignupToken } from './utils.js'; const HOMESERVER_PUBLICKEY = PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo') @@ -9,7 +10,9 @@ test('public: put/get', async (t) => { const keypair = Keypair.random(); - await client.signup(keypair, HOMESERVER_PUBLICKEY); + const signupToken = await createSignupToken(client) + + await client.signup(keypair, HOMESERVER_PUBLICKEY, signupToken); const publicKey = keypair.publicKey(); @@ -57,7 +60,9 @@ test("not found", async (t) => { const keypair = Keypair.random(); - await client.signup(keypair, HOMESERVER_PUBLICKEY); + const signupToken = await createSignupToken(client) + + await client.signup(keypair, HOMESERVER_PUBLICKEY, signupToken); const publicKey = keypair.publicKey(); @@ -74,7 +79,9 @@ test("unauthorized", async (t) => { const keypair = Keypair.random() const publicKey = keypair.publicKey() - await client.signup(keypair, HOMESERVER_PUBLICKEY) + const signupToken = await createSignupToken(client) + + await client.signup(keypair, HOMESERVER_PUBLICKEY, signupToken) const session = await client.session(publicKey) t.ok(session, "signup") @@ -100,7 +107,9 @@ test("forbidden", async (t) => { const keypair = Keypair.random() const publicKey = keypair.publicKey() - await client.signup(keypair, HOMESERVER_PUBLICKEY) + const signupToken = await createSignupToken(client) + + await client.signup(keypair, HOMESERVER_PUBLICKEY, signupToken) const session = await client.session(publicKey) t.ok(session, "signup") @@ -127,7 +136,9 @@ test("list", async (t) => { const publicKey = keypair.publicKey() const pubky = publicKey.z32() - await client.signup(keypair, HOMESERVER_PUBLICKEY) + const signupToken = await createSignupToken(client) + + await client.signup(keypair, HOMESERVER_PUBLICKEY, signupToken) let urls = [ `pubky://${pubky}/pub/a.wrong/a.txt`, @@ -260,7 +271,9 @@ test('list shallow', async (t) => { const publicKey = keypair.publicKey() const pubky = publicKey.z32() - await client.signup(keypair, HOMESERVER_PUBLICKEY) + const signupToken = await createSignupToken(client) + + await client.signup(keypair, HOMESERVER_PUBLICKEY, signupToken) let urls = [ `pubky://${pubky}/pub/a.com/a.txt`, diff --git a/pubky/pkg/test/utils.js b/pubky/pkg/test/utils.js new file mode 100644 index 0000000..a5f2fb5 --- /dev/null +++ b/pubky/pkg/test/utils.js @@ -0,0 +1,25 @@ + +/** + * Util to request a signup token from the given homeserver as admin. + * + * @param {Client} client - An instance of your client. + * @param {string} homeserver_address - The homeserver's public key (as a domain-like string). + * @param {string} [adminPassword="admin"] - The admin password (defaults to "admin"). + * @returns {Promise} - The signup token. + * @throws Will throw an error if the request fails. + */ +export async function createSignupToken(client, homeserver_address ="localhost:6286", adminPassword = "admin") { + const adminUrl = `http://${homeserver_address}/admin/generate_signup_token`; + const response = await client.fetch(adminUrl, { + method: "GET", + headers: { + "X-Admin-Password": adminPassword, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to get signup token: ${response.statusText}`); + } + + return response.text(); +} \ No newline at end of file diff --git a/pubky/src/native/api/auth.rs b/pubky/src/native/api/auth.rs index 2a38057..27bbbaa 100644 --- a/pubky/src/native/api/auth.rs +++ b/pubky/src/native/api/auth.rs @@ -22,17 +22,41 @@ impl Client { /// /// The homeserver is a Pkarr domain name, where the TLD is a Pkarr public key /// for example "pubky.o4dksfbqk85ogzdb5osziw6befigbuxmuxkuxq8434q89uj56uyy" - pub async fn signup(&self, keypair: &Keypair, homeserver: &PublicKey) -> Result { + /// + /// - `keypair`: The user's keypair (used to sign the AuthToken). + /// - `homeserver`: The server's public key (as a domain-like string). + /// - `signup_token`: Optional invite code or token required by the server for new users. + pub async fn signup( + &self, + keypair: &Keypair, + homeserver: &PublicKey, + signup_token: Option<&str>, + ) -> Result { + // 1) Construct the base URL: "https:///signup" + let mut url = Url::parse(&format!("https://{}", homeserver))?; + url.set_path("/signup"); + + // 2) If we have a signup_token, append it to the query string. + if let Some(token) = signup_token { + url.query_pairs_mut().append_pair("signup_token", token); + } + + // 3) Create an AuthToken (e.g. with root capability). + let auth_token = AuthToken::sign(keypair, vec![Capability::root()]); + let request_body = auth_token.serialize(); + + // 4) Send POST request with the AuthToken in the body let response = self - .cross_request(Method::POST, format!("https://{}/signup", homeserver)) + .cross_request(Method::POST, url) .await - .body(AuthToken::sign(keypair, vec![Capability::root()]).serialize()) + .body(request_body) .send() .await?; + // 5) Check for non-2xx status codes handle_http_error!(response); - // Publish homeserver Pkarr record for the first time (force) + // 6) Publish the homeserver record self.publish_homeserver( keypair, Some(&homeserver.to_string()), @@ -40,13 +64,13 @@ impl Client { ) .await?; - // Store the cookie to the correct URL. + // 7) Store session cookie in local store #[cfg(not(target_arch = "wasm32"))] self.cookie_store .store_session_after_signup(&response, &keypair.public_key()); + // 8) Parse the response body into a `Session` let bytes = response.bytes().await?; - Ok(Session::deserialize(&bytes)?) } @@ -349,12 +373,11 @@ impl AuthRequest { #[cfg(test)] mod tests { - use std::time::Duration; - use pkarr::Keypair; use pubky_common::capabilities::{Capabilities, Capability}; use pubky_testnet::Testnet; use reqwest::StatusCode; + use std::time::Duration; #[tokio::test] async fn basic_authn() { @@ -365,7 +388,10 @@ mod tests { let keypair = Keypair::random(); - client.signup(&keypair, &server.public_key()).await.unwrap(); + client + .signup(&keypair, &server.public_key(), None) + .await + .unwrap(); let session = client .session(&keypair.public_key()) @@ -420,7 +446,10 @@ mod tests { { let client = testnet.client_builder().build().unwrap(); - client.signup(&keypair, &server.public_key()).await.unwrap(); + client + .signup(&keypair, &server.public_key(), None) + .await + .unwrap(); client .send_auth_token(&keypair, pubky_auth_request.url()) @@ -480,12 +509,12 @@ mod tests { let second_keypair = Keypair::random(); client - .signup(&first_keypair, &server.public_key()) + .signup(&first_keypair, &server.public_key(), None) .await .unwrap(); client - .signup(&second_keypair, &server.public_key()) + .signup(&second_keypair, &server.public_key(), None) .await .unwrap(); @@ -536,7 +565,10 @@ mod tests { let url = pubky_auth_request.url().clone(); let client = testnet.client_builder().build().unwrap(); - client.signup(&keypair, &server.public_key()).await.unwrap(); + client + .signup(&keypair, &server.public_key(), None) + .await + .unwrap(); tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(400)).await; @@ -587,6 +619,80 @@ mod tests { ); } + #[tokio::test] + async fn test_signup_with_token() { + // 1. Start a test homeserver with closed signups (i.e. signup tokens required) + let testnet = Testnet::run().await.unwrap(); + let server = testnet.run_homeserver_with_signup_tokens().await.unwrap(); + + let admin_password = "admin"; + + let client = testnet.client_builder().build().unwrap(); + let keypair = Keypair::random(); + + // 2. Try to signup with an invalid token "AAAAA" and expect failure. + let invalid_signup = client + .signup(&keypair, &server.public_key(), Some("AAAA-BBBB-CCCC")) + .await; + assert!( + invalid_signup.is_err(), + "Signup should fail with an invalid signup token" + ); + + // 3. Call the admin endpoint to generate a valid signup token. + // The admin endpoint is protected via the header "X-Admin-Password" + // and the password we set up above. + let admin_url = format!( + "https://{}/admin/generate_signup_token", + server.public_key() + ); + + // 3.1. Call the admin endpoint *with a WRONG admin password* to ensure we get 401 UNAUTHORIZED. + let wrong_password_response = client + .get(&admin_url) + .header("X-Admin-Password", "wrong_admin_password") + .send() + .await + .unwrap(); + assert_eq!( + wrong_password_response.status(), + StatusCode::UNAUTHORIZED, + "Wrong admin password should return 401" + ); + + // 3.1 Now call the admin endpoint again, this time with the correct password. + let admin_response = client + .get(&admin_url) + .header("X-Admin-Password", admin_password) + .send() + .await + .unwrap(); + assert_eq!( + admin_response.status(), + StatusCode::OK, + "Admin endpoint should return OK" + ); + let valid_token = admin_response.text().await.unwrap(); // The token string. + + // 4. Now signup with the valid token. Expect success and a session back. + let session = client + .signup(&keypair, &server.public_key(), Some(&valid_token)) + .await + .unwrap(); + assert!( + !session.pubky().to_string().is_empty(), + "Session should contain a valid public key" + ); + + // 5. Finally, sign in with the same keypair and verify that a session is returned. + let signin_session = client.signin(&keypair).await.unwrap(); + assert_eq!( + signin_session.pubky(), + &keypair.public_key(), + "Signed-in session should correspond to the same public key" + ); + } + // This test verifies that when a signin happens immediately after signup, // the record is not republished on signin (its timestamp remains unchanged) // but when a signin happens after the record is “old” (in test, after 1 second), @@ -605,7 +711,10 @@ mod tests { let keypair = Keypair::random(); // Signup publishes a new record. - client.signup(&keypair, &server.public_key()).await.unwrap(); + client + .signup(&keypair, &server.public_key(), None) + .await + .unwrap(); // Resolve the record and get its timestamp. let record1 = client .pkarr() @@ -666,7 +775,10 @@ mod tests { let keypair = Keypair::random(); // Signup publishes a new record. - client.signup(&keypair, &server.public_key()).await.unwrap(); + client + .signup(&keypair, &server.public_key(), None) + .await + .unwrap(); // Resolve the record and get its timestamp. let record1 = client .pkarr() diff --git a/pubky/src/native/api/public.rs b/pubky/src/native/api/public.rs index 610b2f9..0b2596f 100644 --- a/pubky/src/native/api/public.rs +++ b/pubky/src/native/api/public.rs @@ -138,7 +138,10 @@ mod tests { let keypair = Keypair::random(); - client.signup(&keypair, &server.public_key()).await.unwrap(); + client + .signup(&keypair, &server.public_key(), None) + .await + .unwrap(); let url = format!("pubky://{}/pub/foo.txt", keypair.public_key()); let url = url.as_str(); @@ -178,7 +181,10 @@ mod tests { let keypair = Keypair::random(); - client.signup(&keypair, &server.public_key()).await.unwrap(); + client + .signup(&keypair, &server.public_key(), None) + .await + .unwrap(); let public_key = keypair.public_key(); @@ -191,7 +197,7 @@ mod tests { // TODO: remove extra client after switching to subdomains. other_client - .signup(&other, &server.public_key()) + .signup(&other, &server.public_key(), None) .await .unwrap(); @@ -219,7 +225,7 @@ mod tests { // TODO: remove extra client after switching to subdomains. other_client - .signup(&other, &server.public_key()) + .signup(&other, &server.public_key(), None) .await .unwrap(); @@ -243,7 +249,10 @@ mod tests { let keypair = Keypair::random(); - client.signup(&keypair, &server.public_key()).await.unwrap(); + client + .signup(&keypair, &server.public_key(), None) + .await + .unwrap(); let pubky = keypair.public_key(); @@ -446,7 +455,10 @@ mod tests { let keypair = Keypair::random(); - client.signup(&keypair, &server.public_key()).await.unwrap(); + client + .signup(&keypair, &server.public_key(), None) + .await + .unwrap(); let pubky = keypair.public_key(); @@ -656,7 +668,10 @@ mod tests { let keypair = Keypair::random(); - client.signup(&keypair, &server.public_key()).await.unwrap(); + client + .signup(&keypair, &server.public_key(), None) + .await + .unwrap(); let pubky = keypair.public_key(); @@ -752,7 +767,10 @@ mod tests { let keypair = Keypair::random(); - client.signup(&keypair, &server.public_key()).await.unwrap(); + client + .signup(&keypair, &server.public_key(), None) + .await + .unwrap(); let pubky = keypair.public_key(); @@ -805,8 +823,14 @@ mod tests { let user_1 = Keypair::random(); let user_2 = Keypair::random(); - client.signup(&user_1, &homeserver_pubky).await.unwrap(); - client.signup(&user_2, &homeserver_pubky).await.unwrap(); + client + .signup(&user_1, &homeserver_pubky, None) + .await + .unwrap(); + client + .signup(&user_2, &homeserver_pubky, None) + .await + .unwrap(); let user_1_id = user_1.public_key(); let user_2_id = user_2.public_key(); @@ -872,7 +896,10 @@ mod tests { let keypair = Keypair::random(); - client.signup(&keypair, &server.public_key()).await.unwrap(); + client + .signup(&keypair, &server.public_key(), None) + .await + .unwrap(); let url = format!("pubky://{}/pub/foo.txt", keypair.public_key()); let url = url.as_str(); diff --git a/pubky/src/wasm/api/auth.rs b/pubky/src/wasm/api/auth.rs index 4ded56b..00023cc 100644 --- a/pubky/src/wasm/api/auth.rs +++ b/pubky/src/wasm/api/auth.rs @@ -24,10 +24,15 @@ impl Client { &self, keypair: &Keypair, homeserver: &PublicKey, + signup_token: Option, ) -> Result { Ok(Session( self.0 - .signup(keypair.as_inner(), homeserver.as_inner()) + .signup( + keypair.as_inner(), + homeserver.as_inner(), + signup_token.as_deref(), + ) .await .map_err(|e| JsValue::from_str(&e.to_string()))?, ))