mirror of
https://github.com/aljazceru/pubky-core.git
synced 2026-01-31 11:54:19 +01:00
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:
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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:");
|
||||
|
||||
@@ -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?;
|
||||
};
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
#
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
91
pubky-homeserver/src/core/database/tables/signup_tokens.rs
Normal file
91
pubky-homeserver/src/core/database/tables/signup_tokens.rs
Normal 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>;
|
||||
@@ -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> {
|
||||
|
||||
87
pubky-homeserver/src/core/layers/admin.rs
Normal file
87
pubky-homeserver/src/core/layers/admin.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod admin;
|
||||
pub mod authz;
|
||||
pub mod pubky_host;
|
||||
pub mod trace;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
14
pubky-homeserver/src/core/routes/admin.rs
Normal file
14
pubky-homeserver/src/core/routes/admin.rs
Normal 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))
|
||||
}
|
||||
@@ -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 doesn’t 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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
25
pubky/pkg/test/utils.js
Normal 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();
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()))?,
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user