no-password

This commit is contained in:
2025-10-30 01:43:07 +01:00
parent b9966f82a0
commit f89c6bb259
11 changed files with 483 additions and 212 deletions
@@ -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;
+3 -41
View File
@@ -7,7 +7,6 @@ use reqwest::{Client, Method, StatusCode};
use uuid::Uuid;
use backend::{
auth::password,
config::AppConfig,
db::{self, PgPool},
documents::search::ensure_quickwit_index,
@@ -28,11 +27,6 @@ use backend::{
enum Command {
CreateUser {
username: String,
password: String,
},
SetPassword {
username: String,
password: String,
},
ListUsers,
DeleteUser {
@@ -66,8 +60,7 @@ enum Command {
impl Command {
fn usage() -> &'static str {
"Usage: admin\n\
create-user <username> <password>\n\
set-password <username> <password>\n\
create-user <username>\n\
list-users\n\
delete-user <username>\n\
create-tenant <name> [storage_root] [quickwit_index]\n\
@@ -91,11 +84,6 @@ impl Command {
match args.next().as_deref() {
Some("create-user") => Ok(Self::CreateUser {
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("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)?;
match command {
Command::CreateUser { username, password } => create_user(&pool, &username, &password)?,
Command::SetPassword { username, password } => set_password(&pool, &username, &password)?,
Command::CreateUser { username } => create_user(&pool, &username)?,
Command::ListUsers => list_users(&pool)?,
Command::DeleteUser { username } => delete_user(&pool, &username)?,
Command::CreateTenant {
@@ -175,13 +162,10 @@ async fn main() -> Result<()> {
Ok(())
}
fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
fn create_user(pool: &PgPool, username: &str) -> Result<()> {
if username.trim().is_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 exists: bool =
@@ -190,11 +174,9 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
bail!("user '{}' already exists", username);
}
let password_hash = password::hash_password(password).map_err(|err| anyhow!(err))?;
let new_user = NewUser {
id: Uuid::new_v4(),
username: username.to_string(),
password_hash,
};
diesel::insert_into(users::table)
@@ -205,26 +187,6 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
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<()> {
let mut conn = pool.get().context("failed to get database connection")?;
-2
View File
@@ -112,7 +112,6 @@ pub struct Tenant {
pub struct User {
pub id: Uuid,
pub username: String,
pub password_hash: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
@@ -122,7 +121,6 @@ pub struct User {
pub struct NewUser {
pub id: Uuid,
pub username: String,
pub password_hash: String,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)]
+7 -35
View File
@@ -21,7 +21,7 @@ use crate::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
},
password, AuthenticatedUser,
AuthenticatedUser,
},
error::{AppError, AppResult},
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
@@ -93,30 +93,10 @@ pub struct SignupFinishRequest {
pub nickname: Option<String>,
}
pub async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> 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 login(_state: State<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> {
Err(AppError::bad_request(
"password authentication is no longer supported",
))
}
pub async fn signup_start(
@@ -186,9 +166,7 @@ pub async fn signup_finish(
let state_clone = state.clone();
let response = conn.transaction::<Response, AppError, _>(|conn| {
let password_seed = format!("passkey-only-{}", claims.sub);
let password_hash = password::hash_password(&password_seed)?;
insert_user(conn, claims.sub, &claims.username, &password_hash)?;
insert_user(conn, claims.sub, &claims.username)?;
let tenant = state_clone.tenants.create_tenant_with_conn(
conn,
@@ -255,16 +233,10 @@ pub async fn refresh(
issue_session(&state, &mut conn, &user, token.tenant_id)
}
fn insert_user(
conn: &mut PgConnection,
id: Uuid,
username: &str,
password_hash: &str,
) -> AppResult<()> {
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
let new_user = NewUser {
id,
username: username.to_string(),
password_hash: password_hash.to_string(),
};
diesel::insert_into(dsl::users)
-2
View File
@@ -205,8 +205,6 @@ diesel::table! {
id -> Uuid,
#[max_length = 100]
username -> Varchar,
#[max_length = 255]
password_hash -> Varchar,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
+96 -47
View File
@@ -1,19 +1,22 @@
mod common;
use anyhow::{anyhow, Context, Result};
use axum::body::Body;
use axum::http::{header::SET_COOKIE, StatusCode};
use backend::auth::passkeys::{
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
RegistrationChallengeResponse,
};
use backend::models::{NewUserMembership, TenantStatus, UserPasskey};
use backend::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey};
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 diesel::prelude::*;
use rand::rngs::OsRng;
use rand::RngCore;
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use webauthn_rs_core::proto::{
AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential,
@@ -54,6 +57,11 @@ struct TenantSelectionResponse {
tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)]
struct TenantListResponse {
tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)]
struct TenantSummary {
id: Uuid,
@@ -88,10 +96,10 @@ async fn login_rejects_unknown_user() -> Result<()> {
let payload = json!({ "username": "ghost", "password": "nope" });
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 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?;
Ok(())
@@ -399,10 +407,10 @@ async fn login_rejects_invalid_password() -> Result<()> {
let payload = json!({ "username": "robin", "password": "wrong" });
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 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?;
Ok(())
@@ -523,39 +531,36 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
})
.await?;
let payload = json!({ "username": "multipass", "password": password });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
assert!(selection.tenants.len() >= 2);
let secondary = selection
let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?;
let tenants_response = app
.get("/api/auth/tenants", Some(&login.access_token))
.await?;
assert_eq!(tenants_response.status(), StatusCode::OK);
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
.iter()
.find(|tenant| tenant.name == secondary_name)
.map(|t| t.id)
.context("secondary tenant missing from selection")?;
.context("secondary tenant missing from listing")?;
let select_response = app
.post_json(
.post_json_with_cookie(
"/api/auth/select-tenant",
&json!({ "tenant_id": secondary }),
Some(&selection.access_token),
Some(&login.access_token),
Some(&refresh_cookie),
)
.await?;
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 login: LoginResponse = serde_json::from_slice(&select_body)?;
assert_eq!(login.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);
let rotated: LoginResponse = serde_json::from_slice(&select_body)?;
assert_eq!(rotated.tenant.id, secondary);
assert_eq!(rotated.tenant.name, secondary_name);
app.cleanup().await?;
Ok(())
@@ -564,16 +569,60 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
async fn login_with_session(
app: &TestApp,
username: &str,
password: &str,
_password: &str,
) -> Result<(LoginResponse, String)> {
let payload = json!({ "username": username, "password": password });
let response = app.post_json("/api/auth/login", &payload, None).await?;
ensure_status(&response, StatusCode::OK)?;
let refresh_cookie = extract_refresh_cookie(response.headers())?;
let body = body_to_vec(response.into_body()).await?;
let login: LoginResponse = serde_json::from_slice(&body)
.map_err(|_| anyhow!("expected login response with session"))?;
Ok((login, refresh_cookie))
let username = username.to_string();
let state = app.state.clone();
app.with_conn(move |conn| {
use backend::schema::user_memberships::dsl as memberships_dsl;
use backend::schema::users::dsl as users_dsl;
let user: backend::models::User = users_dsl::users
.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> {
@@ -590,14 +639,14 @@ fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
Ok(cookie)
}
fn ensure_status(response: &hyper::Response<Body>, expected: StatusCode) -> Result<()> {
if response.status() == expected {
Ok(())
} else {
Err(anyhow!(
"unexpected status: got {}, expected {}",
response.status(),
expected
))
}
fn generate_refresh_token() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn hash_refresh_token(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
+63 -79
View File
@@ -6,15 +6,20 @@ use std::time::Duration;
use anyhow::{anyhow, ensure, Context, Result};
use async_trait::async_trait;
use axum::body::Body;
use axum::http::{header, Method, Request, StatusCode};
use axum::http::{header, Method, Request};
use axum::Router;
use backend::auth::jwt::JwtService;
use backend::config::AppConfig;
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::schema::refresh_tokens::dsl as refresh_dsl;
use backend::state::AppState;
use backend::storage::ObjectStorage;
use chrono::{Duration as ChronoDuration, Utc};
use diesel::connection::SimpleConnection;
use diesel::prelude::*;
use diesel::OptionalExtension;
@@ -23,8 +28,10 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use http_body_util::BodyExt;
use once_cell::sync::Lazy;
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::{self, json};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use tower::util::ServiceExt;
use uuid::Uuid;
@@ -205,16 +212,13 @@ impl TestApp {
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 password = password.to_string();
let tenant_id = self.ensure_default_tenant().await?;
self.with_conn(move |conn| {
let password_hash = hash_password(&password)?;
let user = NewUser {
id: Uuid::new_v4(),
username,
password_hash,
};
diesel::insert_into(backend::schema::users::table)
.values(&user)
@@ -319,80 +323,59 @@ impl TestApp {
.await
}
pub async fn login_token(&self, username: &str, password: &str) -> Result<String> {
#[derive(Serialize)]
struct LoginPayload<'a> {
username: &'a str,
password: &'a str,
}
pub async fn login_token(&self, username: &str, _password: &str) -> Result<String> {
let (access_token, _, _) = self.create_session(username).await?;
Ok(access_token)
}
let response = self
.post_json(
"/api/auth/login",
&LoginPayload { username, password },
None,
)
.await?;
pub async fn create_session(&self, username: &str) -> Result<(String, String, Uuid)> {
let username = username.to_string();
let state = self.state.clone();
self.with_conn(move |conn| {
use backend::schema::tenants::dsl as tenants_dsl;
use backend::schema::user_memberships::dsl as memberships_dsl;
use backend::schema::users::dsl as users_dsl;
ensure!(
response.status() == StatusCode::OK,
"login failed with status {}",
response.status()
);
let user: User = users_dsl::users
.filter(users_dsl::username.eq(&username))
.first(conn)?;
let body = body_to_vec(response.into_body()).await?;
#[derive(Deserialize)]
struct LoginResponse {
access_token: String,
}
let membership: UserMembership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.first(conn)?;
if let Ok(parsed) = serde_json::from_slice::<LoginResponse>(&body) {
return Ok(parsed.access_token);
}
let tenant: Tenant = tenants_dsl::tenants
.find(membership.tenant_id)
.first(conn)?;
#[derive(Deserialize)]
struct TenantSummary {
id: Uuid,
name: String,
}
let now = Utc::now();
let access_token = state
.jwt
.generate_token(user.id, tenant.id, &user.username)
.map_err(|err| anyhow!(err))?;
#[derive(Deserialize)]
struct TenantSelectionResponse {
access_token: String,
tenants: Vec<TenantSummary>,
}
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 selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
ensure!(
!selection.tenants.is_empty(),
"login returned no tenant options",
);
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,
};
#[derive(Serialize)]
struct SelectTenantPayload {
tenant_id: Uuid,
}
diesel::insert_into(refresh_dsl::refresh_tokens)
.values(&new_refresh)
.execute(conn)?;
let target_tenant = selection.tenants[0].id;
let select_response = self
.post_json(
"/api/auth/select-tenant",
&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)
let cookie = format!("refresh_token={refresh_value}");
Ok((access_token, cookie, tenant.id))
})
.await
}
#[allow(dead_code)]
@@ -747,13 +730,14 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
Ok(())
}
pub fn hash_password(password: &str) -> Result<String> {
use argon2::password_hash::{PasswordHasher, SaltString};
use argon2::Argon2;
fn generate_refresh_token() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
let salt = SaltString::generate(&mut OsRng);
Ok(Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| anyhow!("failed to hash password: {err}"))?
.to_string())
fn hash_refresh_token(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
+2 -6
View File
@@ -7,7 +7,7 @@ use backend::schema::{
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_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 serde::Deserialize;
use serde::Serialize;
@@ -204,8 +204,6 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
let tenant_b_id = Uuid::new_v4();
let user_b_id = Uuid::new_v4();
let password_b = "tenant-b";
app.with_conn(move |conn| {
let storage_root = format!("test-tenants/{tenant_b_id}/");
diesel::insert_into(tenants_dsl::tenants)
@@ -217,11 +215,9 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
))
.execute(conn)?;
let password_hash = hash_password(password_b)?;
let new_user = NewUser {
id: user_b_id,
username: "bob".to_string(),
password_hash,
};
diesel::insert_into(users_dsl::users)
.values(&new_user)
@@ -240,7 +236,7 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
})
.await?;
let token_b = app.login_token("bob", password_b).await?;
let token_b = app.login_token("bob", "").await?;
let create_b = app
.post_json(
+140
View File
@@ -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;
+164
View File
@@ -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()
: {},
};
};