Add SelfFreezoneClient wrapper for Self components

- Created SelfFreezoneClient in Self components
- Wraps SDK FreezoneScriptClient for Self-specific operations
- Implements send_verification_email method
- Uses Rhai script template for email verification
- Includes template variable substitution
- Added serde-wasm-bindgen dependency

Usage:
  let client = SelfFreezoneClient::builder()
      .supervisor_url("http://localhost:8080")
      .secret("my-secret")
      .build()?;

  client.send_verification_email(
      "user@example.com",
      "123456",
      "https://verify.com/abc"
  ).await?;
This commit is contained in:
Timur Gordon
2025-11-03 16:16:18 +01:00
parent be061409af
commit f970f3fb58
33 changed files with 8947 additions and 449 deletions

409
components/src/login.rs Normal file
View File

@@ -0,0 +1,409 @@
use yew::prelude::*;
use web_sys::HtmlInputElement;
use wasm_bindgen::JsCast;
use serde_json;
use wasm_bindgen_futures::spawn_local;
use crate::vault::{Vault, VaultError};
use crate::crypto::KeyPair;
use base64::{Engine as _, engine::general_purpose};
#[derive(Clone, PartialEq, Properties)]
pub struct LoginConfig {
pub server_url: String,
}
#[derive(Clone, PartialEq, Properties)]
pub struct LoginProps {
pub config: LoginConfig,
pub on_login_success: Callback<String>, // Callback with JWT token
}
#[derive(Clone, PartialEq)]
pub enum LoginStatus {
NotStarted,
Processing,
Success,
Failed(String),
}
pub struct Login {
password: String,
private_key: String,
status: LoginStatus,
show_password: bool,
show_private_key: bool,
has_stored_key: bool,
}
#[derive(Clone)]
pub enum LoginMsg {
UpdatePassword(String),
UpdatePrivateKey(String),
TogglePasswordVisibility,
TogglePrivateKeyVisibility,
SubmitLogin,
LoginSuccess(String), // JWT token
LoginFailed(String),
}
impl Component for Login {
type Message = LoginMsg;
type Properties = LoginProps;
fn create(_ctx: &Context<Self>) -> Self {
let has_stored_key = Vault::has_stored_key();
Self {
password: String::new(),
private_key: String::new(),
status: LoginStatus::NotStarted,
show_password: false,
show_private_key: false,
has_stored_key,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
LoginMsg::UpdatePassword(password) => {
self.password = password;
true
}
LoginMsg::UpdatePrivateKey(private_key) => {
self.private_key = private_key;
true
}
LoginMsg::TogglePasswordVisibility => {
self.show_password = !self.show_password;
true
}
LoginMsg::TogglePrivateKeyVisibility => {
self.show_private_key = !self.show_private_key;
true
}
LoginMsg::SubmitLogin => {
if self.validate_form() {
self.submit_login(ctx);
}
true
}
LoginMsg::LoginSuccess(jwt_token) => {
self.status = LoginStatus::Success;
// Store JWT token in localStorage for future use
if let Some(window) = web_sys::window() {
if let Ok(Some(storage)) = window.local_storage() {
let _ = storage.set_item("jwt_token", &jwt_token);
web_sys::console::log_1(&"JWT token stored in localStorage".into());
}
}
ctx.props().on_login_success.emit(jwt_token);
true
}
LoginMsg::LoginFailed(error) => {
self.status = LoginStatus::Failed(error);
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div>
<div style="padding: 2rem;">
<h4 class="mb-4">{"Sign In"}</h4>
{self.render_status_notification()}
<form onsubmit={link.callback(|e: SubmitEvent| {
e.prevent_default();
LoginMsg::SubmitLogin
})}>
<div class="mb-3">
<label class="form-label">
{if self.has_stored_key {
"Password (to decrypt stored key)"
} else {
"Password"
}}
</label>
<div class="input-group">
<input type={if self.show_password { "text" } else { "password" }}
class="form-control"
value={self.password.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
LoginMsg::UpdatePassword(input.value())
})}
placeholder={if self.has_stored_key {
"Enter password to decrypt your key"
} else {
"Enter your password"
}}
required=true />
<button type="button" class="btn btn-outline-secondary"
onclick={link.callback(|_| LoginMsg::TogglePasswordVisibility)}>
<i class={if self.show_password { "bi bi-eye-slash" } else { "bi bi-eye" }}></i>
</button>
</div>
</div>
{if !self.has_stored_key {
html! {
<div class="mb-4">
<label class="form-label">{"Private Key"}</label>
<div class="input-group">
<input type={if self.show_private_key { "text" } else { "password" }}
class="form-control font-monospace"
value={self.private_key.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
LoginMsg::UpdatePrivateKey(input.value())
})}
placeholder="Enter your private key"
required=true />
<button type="button" class="btn btn-outline-secondary"
onclick={link.callback(|_| LoginMsg::TogglePrivateKeyVisibility)}>
<i class={if self.show_private_key { "bi bi-eye-slash" } else { "bi bi-eye" }}></i>
</button>
</div>
<div class="form-text">
{"Your private key will be encrypted and stored securely in your browser after login."}
</div>
</div>
}
} else {
html! {}
}}
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg"
disabled={!self.validate_form() || matches!(self.status, LoginStatus::Processing)}>
{if matches!(self.status, LoginStatus::Processing) {
html! {
<>
<span class="spinner-border spinner-border-sm me-2"></span>
{"Signing In..."}
</>
}
} else {
html! { "Sign In" }
}}
</button>
</div>
</form>
<div class="mt-4">
<p class="text-muted small">
{"Forgot your password? "}
<a href="#" class="text-decoration-none">{"Reset it here"}</a>
</p>
</div>
</div>
</div>
}
}
}
impl Login {
fn validate_form(&self) -> bool {
let password_valid = !self.password.trim().is_empty();
if self.has_stored_key {
password_valid
} else {
password_valid && !self.private_key.trim().is_empty()
}
}
fn submit_login(&mut self, ctx: &Context<Self>) {
self.status = LoginStatus::Processing;
let password = self.password.clone();
let private_key = self.private_key.clone();
let has_stored_key = self.has_stored_key;
let server_url = ctx.props().config.server_url.clone();
let link = ctx.link().clone();
spawn_local(async move {
let (final_private_key, public_key) = if has_stored_key {
// Decrypt stored keypair
match Vault::retrieve_keypair(&password) {
Ok((stored_private_key, stored_public_key)) => {
web_sys::console::log_1(&"Successfully retrieved keypair from vault".into());
(stored_private_key, stored_public_key)
}
Err(e) => {
link.send_message(LoginMsg::LoginFailed(format!("Failed to decrypt stored key: {}", e)));
return;
}
}
} else {
// Derive public key from private key and store both encrypted
let keypair = match KeyPair::from_private_key(&private_key) {
Ok(keypair) => keypair,
Err(e) => {
link.send_message(LoginMsg::LoginFailed(format!("Invalid private key: {}", e)));
return;
}
};
match Vault::store_keypair(&private_key, &keypair.public_key, &password) {
Ok(_) => {
web_sys::console::log_1(&"Keypair stored securely in vault".into());
}
Err(e) => {
web_sys::console::log_1(&format!("Warning: Failed to store keypair in vault: {}", e).into());
}
}
(private_key, keypair.public_key)
};
// Authenticate with identity server using public key
match Self::authenticate_with_server(&server_url, &public_key, &final_private_key).await {
Ok(jwt_token) => {
web_sys::console::log_1(&format!("Authentication successful, received JWT token").into());
link.send_message(LoginMsg::LoginSuccess(jwt_token));
}
Err(e) => {
link.send_message(LoginMsg::LoginFailed(format!("Authentication failed: {}", e)));
}
}
});
}
async fn authenticate_with_server(server_url: &str, public_key: &str, private_key: &str) -> Result<String, String> {
use web_sys::{Request, RequestInit, RequestMode, Response};
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::JsFuture;
// Create authentication challenge
let challenge = format!("auth_challenge_{}", js_sys::Date::now());
// Sign the challenge with private key
let signature = Self::create_auth_signature(&challenge, private_key, public_key)?;
// Prepare authentication request
let auth_request = serde_json::json!({
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": signature,
"public_key": public_key,
"challenge": challenge,
"scope": "openid profile email"
});
let mut opts = RequestInit::new();
opts.method("POST");
opts.mode(RequestMode::Cors);
let headers = js_sys::Object::new();
js_sys::Reflect::set(&headers, &"Content-Type".into(), &"application/json".into()).unwrap();
opts.headers(&headers);
opts.body(Some(&JsValue::from_str(&auth_request.to_string())));
let url = format!("{}/oauth/token", server_url);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|_| "Failed to create request")?;
let window = web_sys::window().ok_or("No window object")?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|_| "Network request failed")?;
let resp: Response = resp_value.dyn_into()
.map_err(|_| "Failed to cast response")?;
if !resp.ok() {
// Get error response body for debugging
let error_json = JsFuture::from(resp.json().map_err(|_| "Failed to get error response JSON")?)
.await
.map_err(|_| "Failed to parse error JSON response")?;
let error_text = js_sys::JSON::stringify(&error_json)
.map_err(|_| "Failed to stringify error response")?
.as_string()
.ok_or("Failed to convert error response to string")?;
return Err(format!("Authentication failed with status: {}\n\nrequest response: {}", resp.status(), error_text));
}
let json = JsFuture::from(resp.json().map_err(|_| "Failed to get response JSON")?)
.await
.map_err(|_| "Failed to parse JSON response")?;
let response_text = js_sys::JSON::stringify(&json)
.map_err(|_| "Failed to stringify response")?
.as_string()
.ok_or("Failed to convert response to string")?;
let response: serde_json::Value = serde_json::from_str(&response_text)
.map_err(|_| "Failed to parse response JSON")?;
response["access_token"]
.as_str()
.map(|s| s.to_string())
.ok_or("No access token in response".to_string())
}
fn create_auth_signature(challenge: &str, private_key: &str, public_key: &str) -> Result<String, String> {
use sha2::{Sha256, Digest};
// Create JWT header
let header = serde_json::json!({
"alg": "HS256",
"typ": "JWT"
});
// Create JWT payload with challenge - subject should be public key
let payload = serde_json::json!({
"iss": "self-sovereign-identity",
"sub": public_key, // Subject is the public key (user identifier)
"aud": "identity-server",
"exp": (js_sys::Date::now() / 1000.0) as u64 + 3600, // 1 hour expiry
"iat": (js_sys::Date::now() / 1000.0) as u64,
"scope": "openid profile email",
"challenge": challenge
});
let header_b64 = general_purpose::STANDARD.encode(header.to_string());
let payload_b64 = general_purpose::STANDARD.encode(payload.to_string());
let unsigned_token = format!("{}.{}", header_b64, payload_b64);
// Sign with private key (simplified HMAC)
let mut hasher = Sha256::new();
hasher.update(unsigned_token.as_bytes());
hasher.update(private_key.as_bytes());
let signature = hasher.finalize();
let signature_b64 = general_purpose::STANDARD.encode(signature);
Ok(format!("{}.{}", unsigned_token, signature_b64))
}
fn render_status_notification(&self) -> Html {
match &self.status {
LoginStatus::NotStarted => html! {},
LoginStatus::Processing => html! {
<div class="alert alert-info mb-3">
<i class="bi bi-hourglass-split me-2"></i>
{"Signing you in..."}
</div>
},
LoginStatus::Success => html! {
<div class="alert alert-success mb-3">
<i class="bi bi-check-circle-fill me-2"></i>
{"Successfully signed in! Redirecting..."}
</div>
},
LoginStatus::Failed(error) => html! {
<div class="alert alert-danger mb-3">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
{error}
</div>
},
}
}
}