no-password
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN password_hash VARCHAR(255) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Optional: remove the default if you need to reintroduce passwords later
|
||||||
|
ALTER TABLE users
|
||||||
|
ALTER COLUMN password_hash DROP DEFAULT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN password_hash;
|
||||||
@@ -7,7 +7,6 @@ use reqwest::{Client, Method, StatusCode};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use backend::{
|
use backend::{
|
||||||
auth::password,
|
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
db::{self, PgPool},
|
db::{self, PgPool},
|
||||||
documents::search::ensure_quickwit_index,
|
documents::search::ensure_quickwit_index,
|
||||||
@@ -28,11 +27,6 @@ use backend::{
|
|||||||
enum Command {
|
enum Command {
|
||||||
CreateUser {
|
CreateUser {
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
|
||||||
},
|
|
||||||
SetPassword {
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
},
|
},
|
||||||
ListUsers,
|
ListUsers,
|
||||||
DeleteUser {
|
DeleteUser {
|
||||||
@@ -66,8 +60,7 @@ enum Command {
|
|||||||
impl Command {
|
impl Command {
|
||||||
fn usage() -> &'static str {
|
fn usage() -> &'static str {
|
||||||
"Usage: admin\n\
|
"Usage: admin\n\
|
||||||
create-user <username> <password>\n\
|
create-user <username>\n\
|
||||||
set-password <username> <password>\n\
|
|
||||||
list-users\n\
|
list-users\n\
|
||||||
delete-user <username>\n\
|
delete-user <username>\n\
|
||||||
create-tenant <name> [storage_root] [quickwit_index]\n\
|
create-tenant <name> [storage_root] [quickwit_index]\n\
|
||||||
@@ -91,11 +84,6 @@ impl Command {
|
|||||||
match args.next().as_deref() {
|
match args.next().as_deref() {
|
||||||
Some("create-user") => Ok(Self::CreateUser {
|
Some("create-user") => Ok(Self::CreateUser {
|
||||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||||
password: args.next().ok_or_else(|| anyhow!("password required"))?,
|
|
||||||
}),
|
|
||||||
Some("set-password") => Ok(Self::SetPassword {
|
|
||||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
|
||||||
password: args.next().ok_or_else(|| anyhow!("password required"))?,
|
|
||||||
}),
|
}),
|
||||||
Some("list-users") => Ok(Self::ListUsers),
|
Some("list-users") => Ok(Self::ListUsers),
|
||||||
Some("delete-user") => Ok(Self::DeleteUser {
|
Some("delete-user") => Ok(Self::DeleteUser {
|
||||||
@@ -141,8 +129,7 @@ async fn main() -> Result<()> {
|
|||||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
Command::CreateUser { username, password } => create_user(&pool, &username, &password)?,
|
Command::CreateUser { username } => create_user(&pool, &username)?,
|
||||||
Command::SetPassword { username, password } => set_password(&pool, &username, &password)?,
|
|
||||||
Command::ListUsers => list_users(&pool)?,
|
Command::ListUsers => list_users(&pool)?,
|
||||||
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
||||||
Command::CreateTenant {
|
Command::CreateTenant {
|
||||||
@@ -175,13 +162,10 @@ async fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
fn create_user(pool: &PgPool, username: &str) -> Result<()> {
|
||||||
if username.trim().is_empty() {
|
if username.trim().is_empty() {
|
||||||
bail!("username must not be empty");
|
bail!("username must not be empty");
|
||||||
}
|
}
|
||||||
if password.is_empty() {
|
|
||||||
bail!("password must not be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
let exists: bool =
|
let exists: bool =
|
||||||
@@ -190,11 +174,9 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
|||||||
bail!("user '{}' already exists", username);
|
bail!("user '{}' already exists", username);
|
||||||
}
|
}
|
||||||
|
|
||||||
let password_hash = password::hash_password(password).map_err(|err| anyhow!(err))?;
|
|
||||||
let new_user = NewUser {
|
let new_user = NewUser {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
password_hash,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(users::table)
|
diesel::insert_into(users::table)
|
||||||
@@ -205,26 +187,6 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_password(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
|
||||||
if password.is_empty() {
|
|
||||||
bail!("password must not be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
|
||||||
let password_hash = password::hash_password(password).map_err(|err| anyhow!(err))?;
|
|
||||||
|
|
||||||
let updated = diesel::update(users::table.filter(users::username.eq(username)))
|
|
||||||
.set(users::password_hash.eq(password_hash))
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
|
|
||||||
if updated == 0 {
|
|
||||||
bail!("user '{}' not found", username);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("updated password for '{}'", username);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_users(pool: &PgPool) -> Result<()> {
|
fn list_users(pool: &PgPool) -> Result<()> {
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ pub struct Tenant {
|
|||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password_hash: String,
|
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
@@ -122,7 +121,6 @@ pub struct User {
|
|||||||
pub struct NewUser {
|
pub struct NewUser {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password_hash: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)]
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use crate::{
|
|||||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||||
},
|
},
|
||||||
password, AuthenticatedUser,
|
AuthenticatedUser,
|
||||||
},
|
},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
||||||
@@ -93,30 +93,10 @@ pub struct SignupFinishRequest {
|
|||||||
pub nickname: Option<String>,
|
pub nickname: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(
|
pub async fn login(_state: State<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> {
|
||||||
State(state): State<AppState>,
|
Err(AppError::bad_request(
|
||||||
Json(payload): Json<LoginRequest>,
|
"password authentication is no longer supported",
|
||||||
) -> AppResult<Response> {
|
))
|
||||||
let mut conn = state.db_unscoped()?;
|
|
||||||
|
|
||||||
let user: Option<User> = dsl::users
|
|
||||||
.filter(dsl::username.eq(&payload.username))
|
|
||||||
.first(&mut conn)
|
|
||||||
.optional()?;
|
|
||||||
|
|
||||||
let user = match user {
|
|
||||||
Some(user) => user,
|
|
||||||
None => return Err(AppError::unauthorized()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let valid = password::verify_password(&payload.password, &user.password_hash)
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
if !valid {
|
|
||||||
return Err(AppError::unauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
complete_login(&state, &mut conn, &user, payload.preferred_tenant_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn signup_start(
|
pub async fn signup_start(
|
||||||
@@ -186,9 +166,7 @@ pub async fn signup_finish(
|
|||||||
|
|
||||||
let state_clone = state.clone();
|
let state_clone = state.clone();
|
||||||
let response = conn.transaction::<Response, AppError, _>(|conn| {
|
let response = conn.transaction::<Response, AppError, _>(|conn| {
|
||||||
let password_seed = format!("passkey-only-{}", claims.sub);
|
insert_user(conn, claims.sub, &claims.username)?;
|
||||||
let password_hash = password::hash_password(&password_seed)?;
|
|
||||||
insert_user(conn, claims.sub, &claims.username, &password_hash)?;
|
|
||||||
|
|
||||||
let tenant = state_clone.tenants.create_tenant_with_conn(
|
let tenant = state_clone.tenants.create_tenant_with_conn(
|
||||||
conn,
|
conn,
|
||||||
@@ -255,16 +233,10 @@ pub async fn refresh(
|
|||||||
issue_session(&state, &mut conn, &user, token.tenant_id)
|
issue_session(&state, &mut conn, &user, token.tenant_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_user(
|
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
|
||||||
conn: &mut PgConnection,
|
|
||||||
id: Uuid,
|
|
||||||
username: &str,
|
|
||||||
password_hash: &str,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let new_user = NewUser {
|
let new_user = NewUser {
|
||||||
id,
|
id,
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
password_hash: password_hash.to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(dsl::users)
|
diesel::insert_into(dsl::users)
|
||||||
|
|||||||
@@ -205,8 +205,6 @@ diesel::table! {
|
|||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
#[max_length = 100]
|
#[max_length = 100]
|
||||||
username -> Varchar,
|
username -> Varchar,
|
||||||
#[max_length = 255]
|
|
||||||
password_hash -> Varchar,
|
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-47
@@ -1,19 +1,22 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{header::SET_COOKIE, StatusCode};
|
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||||
use backend::auth::passkeys::{
|
use backend::auth::passkeys::{
|
||||||
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
||||||
RegistrationChallengeResponse,
|
RegistrationChallengeResponse,
|
||||||
};
|
};
|
||||||
use backend::models::{NewUserMembership, TenantStatus, UserPasskey};
|
use backend::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey};
|
||||||
use backend::openapi::schemas::PasskeySummary;
|
use backend::openapi::schemas::PasskeySummary;
|
||||||
use backend::schema::{tenants, user_memberships, users};
|
use backend::schema::{refresh_tokens, tenants, user_memberships, users};
|
||||||
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use webauthn_rs_core::proto::{
|
use webauthn_rs_core::proto::{
|
||||||
AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential,
|
AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential,
|
||||||
@@ -54,6 +57,11 @@ struct TenantSelectionResponse {
|
|||||||
tenants: Vec<TenantSummary>,
|
tenants: Vec<TenantSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TenantListResponse {
|
||||||
|
tenants: Vec<TenantSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct TenantSummary {
|
struct TenantSummary {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
@@ -88,10 +96,10 @@ async fn login_rejects_unknown_user() -> Result<()> {
|
|||||||
|
|
||||||
let payload = json!({ "username": "ghost", "password": "nope" });
|
let payload = json!({ "username": "ghost", "password": "nope" });
|
||||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||||
assert_eq!(err.error, "unauthorized");
|
assert_eq!(err.error, "password authentication is no longer supported");
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -399,10 +407,10 @@ async fn login_rejects_invalid_password() -> Result<()> {
|
|||||||
|
|
||||||
let payload = json!({ "username": "robin", "password": "wrong" });
|
let payload = json!({ "username": "robin", "password": "wrong" });
|
||||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
let err: ErrorResponse = serde_json::from_slice(&body)?;
|
||||||
assert_eq!(err.error, "unauthorized");
|
assert_eq!(err.error, "password authentication is no longer supported");
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -523,39 +531,36 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let payload = json!({ "username": "multipass", "password": password });
|
let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?;
|
||||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
let tenants_response = app
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
.get("/api/auth/tenants", Some(&login.access_token))
|
||||||
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
|
.await?;
|
||||||
assert!(selection.tenants.len() >= 2);
|
assert_eq!(tenants_response.status(), StatusCode::OK);
|
||||||
let secondary = selection
|
let tenants_body = body_to_vec(tenants_response.into_body()).await?;
|
||||||
|
let tenant_list: TenantListResponse = serde_json::from_slice(&tenants_body)?;
|
||||||
|
assert!(tenant_list.tenants.len() >= 2);
|
||||||
|
|
||||||
|
let secondary = tenant_list
|
||||||
.tenants
|
.tenants
|
||||||
.iter()
|
.iter()
|
||||||
.find(|tenant| tenant.name == secondary_name)
|
.find(|tenant| tenant.name == secondary_name)
|
||||||
.map(|t| t.id)
|
.map(|t| t.id)
|
||||||
.context("secondary tenant missing from selection")?;
|
.context("secondary tenant missing from listing")?;
|
||||||
|
|
||||||
let select_response = app
|
let select_response = app
|
||||||
.post_json(
|
.post_json_with_cookie(
|
||||||
"/api/auth/select-tenant",
|
"/api/auth/select-tenant",
|
||||||
&json!({ "tenant_id": secondary }),
|
&json!({ "tenant_id": secondary }),
|
||||||
Some(&selection.access_token),
|
Some(&login.access_token),
|
||||||
|
Some(&refresh_cookie),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(select_response.status(), StatusCode::OK);
|
assert_eq!(select_response.status(), StatusCode::OK);
|
||||||
let session_cookie = extract_refresh_cookie(select_response.headers())?;
|
|
||||||
let select_body = body_to_vec(select_response.into_body()).await?;
|
let select_body = body_to_vec(select_response.into_body()).await?;
|
||||||
let login: LoginResponse = serde_json::from_slice(&select_body)?;
|
let rotated: LoginResponse = serde_json::from_slice(&select_body)?;
|
||||||
assert_eq!(login.tenant.name, secondary_name);
|
assert_eq!(rotated.tenant.id, secondary);
|
||||||
|
assert_eq!(rotated.tenant.name, secondary_name);
|
||||||
let me_response = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
|
||||||
assert_eq!(me_response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let refresh_response = app
|
|
||||||
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&session_cookie))
|
|
||||||
.await?;
|
|
||||||
assert_eq!(refresh_response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -564,16 +569,60 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
|
|||||||
async fn login_with_session(
|
async fn login_with_session(
|
||||||
app: &TestApp,
|
app: &TestApp,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
_password: &str,
|
||||||
) -> Result<(LoginResponse, String)> {
|
) -> Result<(LoginResponse, String)> {
|
||||||
let payload = json!({ "username": username, "password": password });
|
let username = username.to_string();
|
||||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
let state = app.state.clone();
|
||||||
ensure_status(&response, StatusCode::OK)?;
|
app.with_conn(move |conn| {
|
||||||
let refresh_cookie = extract_refresh_cookie(response.headers())?;
|
use backend::schema::user_memberships::dsl as memberships_dsl;
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
use backend::schema::users::dsl as users_dsl;
|
||||||
let login: LoginResponse = serde_json::from_slice(&body)
|
|
||||||
.map_err(|_| anyhow!("expected login response with session"))?;
|
let user: backend::models::User = users_dsl::users
|
||||||
Ok((login, refresh_cookie))
|
.filter(users_dsl::username.eq(&username))
|
||||||
|
.first(conn)?;
|
||||||
|
|
||||||
|
let membership: backend::models::UserMembership = memberships_dsl::user_memberships
|
||||||
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
|
.first(conn)?;
|
||||||
|
|
||||||
|
let tenant: backend::models::Tenant =
|
||||||
|
tenants::table.find(membership.tenant_id).first(conn)?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let access_token = state
|
||||||
|
.jwt
|
||||||
|
.generate_token(user.id, tenant.id, &user.username)
|
||||||
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
|
||||||
|
let refresh_value = generate_refresh_token();
|
||||||
|
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||||
|
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
|
|
||||||
|
let new_refresh = NewRefreshToken {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: user.id,
|
||||||
|
token_hash: refresh_hash,
|
||||||
|
issued_at: now.naive_utc(),
|
||||||
|
expires_at: refresh_expires_at.naive_utc(),
|
||||||
|
tenant_id: tenant.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(refresh_tokens::table)
|
||||||
|
.values(&new_refresh)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
let login = LoginResponse {
|
||||||
|
access_token,
|
||||||
|
tenant: LoginTenant {
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let cookie = format!("refresh_token={refresh_value}");
|
||||||
|
Ok((login, cookie))
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
||||||
@@ -590,14 +639,14 @@ fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
|||||||
Ok(cookie)
|
Ok(cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_status(response: &hyper::Response<Body>, expected: StatusCode) -> Result<()> {
|
fn generate_refresh_token() -> String {
|
||||||
if response.status() == expected {
|
let mut bytes = [0u8; 32];
|
||||||
Ok(())
|
OsRng.fill_bytes(&mut bytes);
|
||||||
} else {
|
hex::encode(bytes)
|
||||||
Err(anyhow!(
|
}
|
||||||
"unexpected status: got {}, expected {}",
|
|
||||||
response.status(),
|
fn hash_refresh_token(value: &str) -> String {
|
||||||
expected
|
let mut hasher = Sha256::new();
|
||||||
))
|
hasher.update(value.as_bytes());
|
||||||
}
|
hex::encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-79
@@ -6,15 +6,20 @@ use std::time::Duration;
|
|||||||
use anyhow::{anyhow, ensure, Context, Result};
|
use anyhow::{anyhow, ensure, Context, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{header, Method, Request, StatusCode};
|
use axum::http::{header, Method, Request};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use backend::auth::jwt::JwtService;
|
use backend::auth::jwt::JwtService;
|
||||||
use backend::config::AppConfig;
|
use backend::config::AppConfig;
|
||||||
use backend::db::{self, PgPool};
|
use backend::db::{self, PgPool};
|
||||||
use backend::models::{Job, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus};
|
use backend::models::{
|
||||||
|
Job, NewRefreshToken, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus, User,
|
||||||
|
UserMembership,
|
||||||
|
};
|
||||||
use backend::routes;
|
use backend::routes;
|
||||||
|
use backend::schema::refresh_tokens::dsl as refresh_dsl;
|
||||||
use backend::state::AppState;
|
use backend::state::AppState;
|
||||||
use backend::storage::ObjectStorage;
|
use backend::storage::ObjectStorage;
|
||||||
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use diesel::connection::SimpleConnection;
|
use diesel::connection::SimpleConnection;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::OptionalExtension;
|
use diesel::OptionalExtension;
|
||||||
@@ -23,8 +28,10 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
|||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{self, json};
|
use serde_json::{self, json};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -205,16 +212,13 @@ impl TestApp {
|
|||||||
Ok(format!("{}{}", root, key))
|
Ok(format!("{}{}", root, key))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert_user(&self, username: &str, password: &str, _role: &str) -> Result<Uuid> {
|
pub async fn insert_user(&self, username: &str, _password: &str, _role: &str) -> Result<Uuid> {
|
||||||
let username = username.to_string();
|
let username = username.to_string();
|
||||||
let password = password.to_string();
|
|
||||||
let tenant_id = self.ensure_default_tenant().await?;
|
let tenant_id = self.ensure_default_tenant().await?;
|
||||||
self.with_conn(move |conn| {
|
self.with_conn(move |conn| {
|
||||||
let password_hash = hash_password(&password)?;
|
|
||||||
let user = NewUser {
|
let user = NewUser {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
username,
|
username,
|
||||||
password_hash,
|
|
||||||
};
|
};
|
||||||
diesel::insert_into(backend::schema::users::table)
|
diesel::insert_into(backend::schema::users::table)
|
||||||
.values(&user)
|
.values(&user)
|
||||||
@@ -319,80 +323,59 @@ impl TestApp {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login_token(&self, username: &str, password: &str) -> Result<String> {
|
pub async fn login_token(&self, username: &str, _password: &str) -> Result<String> {
|
||||||
#[derive(Serialize)]
|
let (access_token, _, _) = self.create_session(username).await?;
|
||||||
struct LoginPayload<'a> {
|
Ok(access_token)
|
||||||
username: &'a str,
|
}
|
||||||
password: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = self
|
pub async fn create_session(&self, username: &str) -> Result<(String, String, Uuid)> {
|
||||||
.post_json(
|
let username = username.to_string();
|
||||||
"/api/auth/login",
|
let state = self.state.clone();
|
||||||
&LoginPayload { username, password },
|
self.with_conn(move |conn| {
|
||||||
None,
|
use backend::schema::tenants::dsl as tenants_dsl;
|
||||||
)
|
use backend::schema::user_memberships::dsl as memberships_dsl;
|
||||||
.await?;
|
use backend::schema::users::dsl as users_dsl;
|
||||||
|
|
||||||
ensure!(
|
let user: User = users_dsl::users
|
||||||
response.status() == StatusCode::OK,
|
.filter(users_dsl::username.eq(&username))
|
||||||
"login failed with status {}",
|
.first(conn)?;
|
||||||
response.status()
|
|
||||||
);
|
|
||||||
|
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
let membership: UserMembership = memberships_dsl::user_memberships
|
||||||
#[derive(Deserialize)]
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
struct LoginResponse {
|
.first(conn)?;
|
||||||
access_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(parsed) = serde_json::from_slice::<LoginResponse>(&body) {
|
let tenant: Tenant = tenants_dsl::tenants
|
||||||
return Ok(parsed.access_token);
|
.find(membership.tenant_id)
|
||||||
}
|
.first(conn)?;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
let now = Utc::now();
|
||||||
struct TenantSummary {
|
let access_token = state
|
||||||
id: Uuid,
|
.jwt
|
||||||
name: String,
|
.generate_token(user.id, tenant.id, &user.username)
|
||||||
}
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
let refresh_value = generate_refresh_token();
|
||||||
struct TenantSelectionResponse {
|
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||||
access_token: String,
|
let refresh_expires_at =
|
||||||
tenants: Vec<TenantSummary>,
|
now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
}
|
|
||||||
|
|
||||||
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
|
let new_refresh = NewRefreshToken {
|
||||||
ensure!(
|
id: Uuid::new_v4(),
|
||||||
!selection.tenants.is_empty(),
|
user_id: user.id,
|
||||||
"login returned no tenant options",
|
token_hash: refresh_hash,
|
||||||
);
|
issued_at: now.naive_utc(),
|
||||||
|
expires_at: refresh_expires_at.naive_utc(),
|
||||||
|
tenant_id: tenant.id,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
diesel::insert_into(refresh_dsl::refresh_tokens)
|
||||||
struct SelectTenantPayload {
|
.values(&new_refresh)
|
||||||
tenant_id: Uuid,
|
.execute(conn)?;
|
||||||
}
|
|
||||||
|
|
||||||
let target_tenant = selection.tenants[0].id;
|
let cookie = format!("refresh_token={refresh_value}");
|
||||||
let select_response = self
|
Ok((access_token, cookie, tenant.id))
|
||||||
.post_json(
|
})
|
||||||
"/api/auth/select-tenant",
|
.await
|
||||||
&SelectTenantPayload {
|
|
||||||
tenant_id: target_tenant,
|
|
||||||
},
|
|
||||||
Some(&selection.access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
ensure!(
|
|
||||||
select_response.status() == StatusCode::OK,
|
|
||||||
"tenant selection failed with status {}",
|
|
||||||
select_response.status()
|
|
||||||
);
|
|
||||||
|
|
||||||
let select_body = body_to_vec(select_response.into_body()).await?;
|
|
||||||
let parsed: LoginResponse = serde_json::from_slice(&select_body)?;
|
|
||||||
Ok(parsed.access_token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -747,13 +730,14 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hash_password(password: &str) -> Result<String> {
|
fn generate_refresh_token() -> String {
|
||||||
use argon2::password_hash::{PasswordHasher, SaltString};
|
let mut bytes = [0u8; 32];
|
||||||
use argon2::Argon2;
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
fn hash_refresh_token(value: &str) -> String {
|
||||||
Ok(Argon2::default()
|
let mut hasher = Sha256::new();
|
||||||
.hash_password(password.as_bytes(), &salt)
|
hasher.update(value.as_bytes());
|
||||||
.map_err(|err| anyhow!("failed to hash password: {err}"))?
|
hex::encode(hasher.finalize())
|
||||||
.to_string())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use backend::schema::{
|
|||||||
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
|
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
|
||||||
users::dsl as users_dsl,
|
users::dsl as users_dsl,
|
||||||
};
|
};
|
||||||
use common::{acquire_db_lock, body_to_vec, hash_password, TestApp};
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -204,8 +204,6 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
|
|||||||
|
|
||||||
let tenant_b_id = Uuid::new_v4();
|
let tenant_b_id = Uuid::new_v4();
|
||||||
let user_b_id = Uuid::new_v4();
|
let user_b_id = Uuid::new_v4();
|
||||||
let password_b = "tenant-b";
|
|
||||||
|
|
||||||
app.with_conn(move |conn| {
|
app.with_conn(move |conn| {
|
||||||
let storage_root = format!("test-tenants/{tenant_b_id}/");
|
let storage_root = format!("test-tenants/{tenant_b_id}/");
|
||||||
diesel::insert_into(tenants_dsl::tenants)
|
diesel::insert_into(tenants_dsl::tenants)
|
||||||
@@ -217,11 +215,9 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
|
|||||||
))
|
))
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
|
|
||||||
let password_hash = hash_password(password_b)?;
|
|
||||||
let new_user = NewUser {
|
let new_user = NewUser {
|
||||||
id: user_b_id,
|
id: user_b_id,
|
||||||
username: "bob".to_string(),
|
username: "bob".to_string(),
|
||||||
password_hash,
|
|
||||||
};
|
};
|
||||||
diesel::insert_into(users_dsl::users)
|
diesel::insert_into(users_dsl::users)
|
||||||
.values(&new_user)
|
.values(&new_user)
|
||||||
@@ -240,7 +236,7 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let token_b = app.login_token("bob", password_b).await?;
|
let token_b = app.login_token("bob", "").await?;
|
||||||
|
|
||||||
let create_b = app
|
let create_b = app
|
||||||
.post_json(
|
.post_json(
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
isWebAuthnAvailable,
|
||||||
|
preparePublicKeyCreationOptions,
|
||||||
|
serializeRegistrationCredential,
|
||||||
|
} from '../utils/webauthn';
|
||||||
|
|
||||||
|
const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }) => {
|
||||||
|
const [passkeys, setPasskeys] = useState([]);
|
||||||
|
const [passkeysSupported, setPasskeysSupported] = useState(null);
|
||||||
|
const [passkeysLoading, setPasskeysLoading] = useState(false);
|
||||||
|
const [registeringPasskey, setRegisteringPasskey] = useState(false);
|
||||||
|
const [revokingPasskeyId, setRevokingPasskeyId] = useState(null);
|
||||||
|
|
||||||
|
const refreshPasskeys = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPasskeysLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/profile/passkeys');
|
||||||
|
setPasskeys(Array.isArray(data) ? data : []);
|
||||||
|
setPasskeysSupported(true);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
if (status === 400 || status === 404) {
|
||||||
|
setPasskeysSupported(false);
|
||||||
|
setPasskeys([]);
|
||||||
|
} else {
|
||||||
|
notifyApiError(error, 'Failed to load passkeys.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPasskeysLoading(false);
|
||||||
|
}
|
||||||
|
}, [api, notifyApiError, token]);
|
||||||
|
|
||||||
|
const registerPasskey = useCallback(
|
||||||
|
async ({ nickname } = {}) => {
|
||||||
|
if (!isWebAuthnAvailable()) {
|
||||||
|
setPasskeysSupported(false);
|
||||||
|
setStatusMessage('Passkeys are not supported in this browser.', 'error');
|
||||||
|
return { ok: false, reason: 'unsupported' };
|
||||||
|
}
|
||||||
|
if (registeringPasskey) {
|
||||||
|
return { ok: false, reason: 'busy' };
|
||||||
|
}
|
||||||
|
|
||||||
|
setRegisteringPasskey(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/auth/passkeys/register/start', {});
|
||||||
|
const challengeId = data?.challengeId || data?.challenge_id;
|
||||||
|
const publicKeyOptions =
|
||||||
|
data?.publicKey
|
||||||
|
|| data?.public_key
|
||||||
|
|| data?.challenge?.publicKey
|
||||||
|
|| data?.publicKeyCredentialCreationOptions;
|
||||||
|
|
||||||
|
if (!challengeId || !publicKeyOptions) {
|
||||||
|
throw new Error('Invalid passkey challenge response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
|
||||||
|
const credential = await navigator.credentials.create({ publicKey });
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
return { ok: false, reason: 'cancelled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = serializeRegistrationCredential(credential);
|
||||||
|
const payload = {
|
||||||
|
challengeId,
|
||||||
|
credential: serialized,
|
||||||
|
};
|
||||||
|
const trimmedNickname = nickname?.trim();
|
||||||
|
if (trimmedNickname) {
|
||||||
|
payload.nickname = trimmedNickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.post('/auth/passkeys/register/finish', payload);
|
||||||
|
await refreshPasskeys();
|
||||||
|
setPasskeysSupported(true);
|
||||||
|
setStatusMessage('Passkey registered.', 'success');
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'NotAllowedError') {
|
||||||
|
setStatusMessage('Passkey registration cancelled.', 'info');
|
||||||
|
return { ok: false, reason: 'cancelled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = error?.response?.status;
|
||||||
|
if (status === 400 || status === 404) {
|
||||||
|
setPasskeysSupported(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error?.response?.data?.error || 'Failed to register passkey.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return { ok: false, reason: 'error', message };
|
||||||
|
} finally {
|
||||||
|
setRegisteringPasskey(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, notifyApiError, refreshPasskeys, registeringPasskey, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const revokePasskey = useCallback(
|
||||||
|
async (passkeyId, reason) => {
|
||||||
|
if (!passkeyId) {
|
||||||
|
return { ok: false, reason: 'missing-id' };
|
||||||
|
}
|
||||||
|
setRevokingPasskeyId(passkeyId);
|
||||||
|
try {
|
||||||
|
const query = reason ? `?reason=${encodeURIComponent(reason)}` : '';
|
||||||
|
await api.delete(`/profile/passkeys/${passkeyId}${query}`);
|
||||||
|
await refreshPasskeys();
|
||||||
|
setStatusMessage('Passkey revoked.', 'success');
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.response?.data?.error || 'Failed to revoke passkey.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
return { ok: false, reason: 'error', message };
|
||||||
|
} finally {
|
||||||
|
setRevokingPasskeyId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, notifyApiError, refreshPasskeys, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
passkeys,
|
||||||
|
passkeysSupported,
|
||||||
|
passkeysLoading,
|
||||||
|
registeringPasskey,
|
||||||
|
revokingPasskeyId,
|
||||||
|
refreshPasskeys,
|
||||||
|
registerPasskey,
|
||||||
|
revokePasskey,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePasskeys;
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
const base64urlToBase64 = (value = '') => {
|
||||||
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padding = normalized.length % 4;
|
||||||
|
if (padding === 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
const padLength = 4 - padding;
|
||||||
|
return normalized + '='.repeat(padLength);
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64ToBase64url = (value = '') =>
|
||||||
|
value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
|
||||||
|
const decodeBase64 = (value) => {
|
||||||
|
if (typeof window !== 'undefined' && typeof window.atob === 'function') {
|
||||||
|
return window.atob(value);
|
||||||
|
}
|
||||||
|
const bufferCtor = typeof globalThis !== 'undefined' ? globalThis.Buffer : undefined;
|
||||||
|
if (bufferCtor) {
|
||||||
|
return bufferCtor.from(value, 'base64').toString('binary');
|
||||||
|
}
|
||||||
|
throw new Error('No base64 decoder available.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodeBase64 = (binary) => {
|
||||||
|
if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
|
||||||
|
return window.btoa(binary);
|
||||||
|
}
|
||||||
|
const bufferCtor = typeof globalThis !== 'undefined' ? globalThis.Buffer : undefined;
|
||||||
|
if (bufferCtor) {
|
||||||
|
return bufferCtor.from(binary, 'binary').toString('base64');
|
||||||
|
}
|
||||||
|
throw new Error('No base64 encoder available.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const base64urlToUint8Array = (value) => {
|
||||||
|
const base64 = base64urlToBase64(value || '');
|
||||||
|
const binary = decodeBase64(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const arrayBufferToBase64url = (buffer) => {
|
||||||
|
const bytes = new Uint8Array(buffer || []);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
const base64 = encodeBase64(binary);
|
||||||
|
return base64ToBase64url(base64);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isWebAuthnAvailable = () =>
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
&& typeof navigator !== 'undefined'
|
||||||
|
&& navigator.credentials
|
||||||
|
&& typeof navigator.credentials.create === 'function'
|
||||||
|
&& typeof navigator.credentials.get === 'function';
|
||||||
|
|
||||||
|
export const preparePublicKeyCreationOptions = (challengeResponse) => {
|
||||||
|
if (!challengeResponse || !challengeResponse.publicKey) {
|
||||||
|
throw new Error('Missing publicKey challenge options.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = { ...challengeResponse.publicKey };
|
||||||
|
|
||||||
|
if (publicKey.challenge) {
|
||||||
|
publicKey.challenge = base64urlToUint8Array(publicKey.challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicKey.user?.id) {
|
||||||
|
publicKey.user = {
|
||||||
|
...publicKey.user,
|
||||||
|
id: base64urlToUint8Array(publicKey.user.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(publicKey.excludeCredentials)) {
|
||||||
|
publicKey.excludeCredentials = publicKey.excludeCredentials.map((descriptor) => ({
|
||||||
|
...descriptor,
|
||||||
|
id: base64urlToUint8Array(descriptor.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicKey.authenticatorSelection?.residentKey === 'discouraged' && !publicKey.authenticatorSelection.requireResidentKey) {
|
||||||
|
delete publicKey.authenticatorSelection.requireResidentKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preparePublicKeyRequestOptions = (challengeResponse) => {
|
||||||
|
if (!challengeResponse || !challengeResponse.publicKey) {
|
||||||
|
throw new Error('Missing publicKey request options.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = { ...challengeResponse.publicKey };
|
||||||
|
|
||||||
|
if (publicKey.challenge) {
|
||||||
|
publicKey.challenge = base64urlToUint8Array(publicKey.challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(publicKey.allowCredentials)) {
|
||||||
|
publicKey.allowCredentials = publicKey.allowCredentials.map((descriptor) => ({
|
||||||
|
...descriptor,
|
||||||
|
id: base64urlToUint8Array(descriptor.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeRegistrationCredential = (credential) => {
|
||||||
|
if (!credential) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transports =
|
||||||
|
typeof credential?.response?.getTransports === 'function'
|
||||||
|
? credential.response.getTransports()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
type: credential.type,
|
||||||
|
rawId: arrayBufferToBase64url(credential.rawId),
|
||||||
|
response: {
|
||||||
|
clientDataJSON: arrayBufferToBase64url(credential.response.clientDataJSON),
|
||||||
|
attestationObject: arrayBufferToBase64url(credential.response.attestationObject),
|
||||||
|
transports: transports && transports.length ? Array.from(transports) : undefined,
|
||||||
|
},
|
||||||
|
clientExtensionResults:
|
||||||
|
typeof credential.getClientExtensionResults === 'function'
|
||||||
|
? credential.getClientExtensionResults()
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeAuthenticationCredential = (credential) => {
|
||||||
|
if (!credential) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
type: credential.type,
|
||||||
|
rawId: arrayBufferToBase64url(credential.rawId),
|
||||||
|
response: {
|
||||||
|
clientDataJSON: arrayBufferToBase64url(credential.response.clientDataJSON),
|
||||||
|
authenticatorData: arrayBufferToBase64url(credential.response.authenticatorData),
|
||||||
|
signature: arrayBufferToBase64url(credential.response.signature),
|
||||||
|
userHandle: credential.response.userHandle
|
||||||
|
? arrayBufferToBase64url(credential.response.userHandle)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
clientExtensionResults:
|
||||||
|
typeof credential.getClientExtensionResults === 'function'
|
||||||
|
? credential.getClientExtensionResults()
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user