feat: signup tokens (#80)

* 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>
This commit is contained in:
SHAcollision
2025-03-17 15:58:58 -04:00
committed by GitHub
parent b685f8a085
commit 6386f1ae43
28 changed files with 771 additions and 129 deletions

View File

@@ -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 <homeserver pubky> </path/to/recovery file>
cargo run --bin signup <homeserver pubky> </path/to/recovery file> <signup_token>
```

View File

@@ -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<String>,
}
#[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:");

View File

@@ -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?;
};

View File

@@ -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://<homeserver_ip:port>/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`

View File

@@ -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 <System's Data Directory>
#

View File

@@ -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<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
struct AdminToml {
pub password: Option<String>,
/// "open" or "token_required" (defaults to "token_required", i.e., a signup token is required)
pub signup_mode: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
struct IoToml {
pub http_port: Option<u16>,
@@ -51,9 +61,9 @@ struct IoToml {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
struct ConfigToml {
secret_key: Option<String>,
database: Option<DatabaseToml>,
io: Option<IoToml>,
admin: Option<AdminToml>,
}
/// 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<ConfigToml> for Config {
type Error = anyhow::Error;
@@ -175,14 +187,26 @@ impl TryFrom<ConfigToml> 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
}
}
)
}

View File

@@ -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(())
}

View File

@@ -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"),
})
}
}

View File

@@ -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<usize> {

View File

@@ -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<PublicKey>,
}
impl SignupToken {
pub fn serialize(&self) -> Vec<u8> {
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<String> {
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<Str, Bytes>;

View File

@@ -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<Cow<[u8]>, 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<Cow<[u8]>, BoxedError> {

View File

@@ -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<S> Layer<S> for AdminAuthLayer {
type Service = AdminAuthMiddleware<S>;
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<S> {
inner: S,
password: String,
}
impl<S, ReqBody> Service<Request<ReqBody>> for AdminAuthMiddleware<S>
where
S: Service<Request<ReqBody>, 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<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<ReqBody>) -> 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)
}
}
})
}
}

View File

@@ -1,3 +1,4 @@
pub mod admin;
pub mod authz;
pub mod pubky_host;
pub mod trace;

View File

@@ -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<Self> {
pub unsafe fn new(config: CoreConfig, admin: AdminConfig) -> Result<Self> {
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<Self> {
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<String>,
/// 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 {

View File

@@ -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<AppState>) -> Result<impl IntoResponse> {
let token = state.db.generate_signup_token()?;
Ok((StatusCode::OK, token))
}
pub fn router(state: AppState) -> Router<AppState> {
let admin_password = state.admin.password.unwrap_or_default();
Router::new()
.route("/generate_signup_token", get(generate_signup_token))
.layer(AdminAuthLayer::new(admin_password))
}

View File

@@ -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<AppState>,
user_agent: Option<TypedHeader<UserAgent>>,
cookies: Cookies,
host: Host,
Host(host): Host,
Query(params): Query<HashMap<String, String>>, // for extracting `signup_token` if needed
body: Bytes,
) -> Result<impl IntoResponse> {
// 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 doesnt exist, otherwise logs them in by creating a session.
pub async fn signin(
State(state): State<AppState>,
user_agent: Option<TypedHeader<UserAgent>>,
@@ -26,54 +87,68 @@ pub async fn signin(
Host(host): Host,
body: Bytes,
) -> Result<impl IntoResponse> {
// 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<TypedHeader<UserAgent>>,
) -> Result<impl IntoResponse> {
// 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)

View File

@@ -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<AppState> {
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)
}

View File

@@ -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<Self> {
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?;

View File

@@ -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> {
Homeserver::run_test_with_signup_tokens(&self.dht.bootstrap).await
}
/// Run an HTTP Relay
pub async fn run_http_relay(&self) -> Result<HttpRelay> {
HttpRelay::builder().run().await

View File

@@ -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());

View File

@@ -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();
```

View File

@@ -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",

View File

@@ -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)
}

View File

@@ -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`,

25
pubky/pkg/test/utils.js Normal file
View File

@@ -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<string>} - 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();
}

View File

@@ -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<Session> {
///
/// - `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<Session> {
// 1) Construct the base URL: "https://<homeserver>/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()

View File

@@ -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();

View File

@@ -24,10 +24,15 @@ impl Client {
&self,
keypair: &Keypair,
homeserver: &PublicKey,
signup_token: Option<String>,
) -> Result<Session, JsValue> {
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()))?,
))