feat(oauth): discoverable passkey authentication

Lewis: May this revision serve well! <lu5a@proton.me>
This commit is contained in:
Lewis
2026-04-12 10:38:31 +03:00
committed by Tangled
parent 52c22060f3
commit 34f050a1f0
28 changed files with 751 additions and 269 deletions

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
"query": "SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
true
]
},
"hash": "c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038"
"hash": "053c971024b0d29a441c3597d760b3e21db2383442c3e6f09de4eb49ea437e7c"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR email = $1\n ",
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR did = $1\n ",
"describe": {
"columns": [
{
@@ -118,5 +118,5 @@
false
]
},
"hash": "7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa"
"hash": "060c285c93a05252aab7d474df0186e7b5083fafedc582b8eac9916983e8fc2d"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "3155ef4f35698a3fe6aa38d5d976fd51b7f6a0381c81c4907dad61d2f37992bd"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW()\n ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "state_json",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "6969c478a0922bac4b79902313a0e28c94d6b8d6b16035474dd8f484e6171d60"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1",
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.did = $1",
"describe": {
"columns": [
{
@@ -132,5 +132,5 @@
null
]
},
"hash": "a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249"
"hash": "aafc2a7e51200ca1e7071c63c13698bf34ef8b66758ca9ebab4ea706ffb62914"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'discoverable', $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Bytea",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "c6e3388fc39983f1787917606ba3194c72322d2d1ec54402c262194791a2b06a"
}

45
Cargo.lock generated
View File

@@ -7405,7 +7405,7 @@ dependencies = [
[[package]]
name = "tranquil-api"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"axum",
@@ -7456,7 +7456,7 @@ dependencies = [
[[package]]
name = "tranquil-auth"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"base32",
@@ -7479,7 +7479,7 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7493,7 +7493,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7511,7 +7511,7 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"confique",
"serde",
@@ -7519,7 +7519,7 @@ dependencies = [
[[package]]
name = "tranquil-crypto"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -7535,7 +7535,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"async-trait",
"chrono",
@@ -7552,7 +7552,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7568,7 +7568,7 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"async-trait",
"bytes",
@@ -7579,7 +7579,7 @@ dependencies = [
[[package]]
name = "tranquil-lexicon"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"chrono",
"hickory-resolver",
@@ -7597,7 +7597,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"axum",
@@ -7620,7 +7620,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth-server"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"axum",
"base64 0.22.1",
@@ -7653,7 +7653,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"aes-gcm",
"anyhow",
@@ -7738,13 +7738,14 @@ dependencies = [
"urlencoding",
"uuid",
"webauthn-rs",
"webauthn-rs-proto",
"wiremock",
"zip",
]
[[package]]
name = "tranquil-repo"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"bytes",
"cid",
@@ -7756,7 +7757,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"async-trait",
"backon",
@@ -7781,7 +7782,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"axum",
"futures",
@@ -7797,7 +7798,7 @@ dependencies = [
[[package]]
name = "tranquil-server"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"axum",
"clap",
@@ -7818,7 +7819,7 @@ dependencies = [
[[package]]
name = "tranquil-signal"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"async-trait",
"chrono",
@@ -7841,7 +7842,7 @@ dependencies = [
[[package]]
name = "tranquil-storage"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"async-trait",
"aws-config",
@@ -7858,7 +7859,7 @@ dependencies = [
[[package]]
name = "tranquil-store"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"async-trait",
"bytes",
@@ -7904,7 +7905,7 @@ dependencies = [
[[package]]
name = "tranquil-sync"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"axum",
@@ -7926,7 +7927,7 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"chrono",
"cid",

View File

@@ -26,7 +26,7 @@ members = [
]
[workspace.package]
version = "0.5.0"
version = "0.5.1"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -126,7 +126,7 @@ tracing = "0.1"
tracing-subscriber = "0.3"
urlencoding = "2.1"
uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] }
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys", "conditional-ui"] }
webauthn-rs-proto = "0.5"
zip = { version = "7.0", default-features = false, features = ["deflate"] }

View File

@@ -144,12 +144,12 @@ pub trait UserRepository: Send + Sync {
async fn get_by_email(&self, email: &str) -> Result<Option<UserForVerification>, DbError>;
async fn get_login_check_by_handle_or_email(
async fn get_login_check_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginCheck>, DbError>;
async fn get_login_info_by_handle_or_email(
async fn get_login_info_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginInfo>, DbError>;
@@ -358,6 +358,19 @@ pub trait UserRepository: Send + Sync {
challenge_type: WebauthnChallengeType,
) -> Result<(), DbError>;
async fn save_discoverable_challenge(
&self,
request_key: &str,
state_json: &str,
) -> Result<Uuid, DbError>;
async fn load_discoverable_challenge(
&self,
request_key: &str,
) -> Result<Option<String>, DbError>;
async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError>;
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError>;
async fn get_totp_record_state(&self, did: &Did) -> Result<Option<TotpRecordState>, DbError>;

View File

@@ -1102,6 +1102,59 @@ impl UserRepository for PostgresUserRepository {
Ok(())
}
async fn save_discoverable_challenge(
&self,
request_key: &str,
state_json: &str,
) -> Result<Uuid, DbError> {
let id = Uuid::new_v4();
let challenge = id.as_bytes().to_vec();
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5);
sqlx::query!(
r#"INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)
VALUES ($1, $2, $3, 'discoverable', $4, $5)"#,
id,
request_key,
challenge,
state_json,
expires_at,
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(id)
}
async fn load_discoverable_challenge(
&self,
request_key: &str,
) -> Result<Option<String>, DbError> {
let row = sqlx::query_scalar!(
r#"SELECT state_json FROM webauthn_challenges
WHERE did = $1 AND challenge_type = 'discoverable' AND expires_at > NOW()
ORDER BY created_at DESC LIMIT 1"#,
request_key,
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row)
}
async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError> {
sqlx::query!(
"DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'discoverable'",
request_key,
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError> {
let row = sqlx::query!(
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
@@ -1330,12 +1383,12 @@ impl UserRepository for PostgresUserRepository {
Ok(())
}
async fn get_login_check_by_handle_or_email(
async fn get_login_check_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginCheck>, DbError> {
sqlx::query!(
"SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1",
"SELECT did, password_hash FROM users WHERE handle = $1 OR did = $1",
identifier
)
.fetch_optional(&self.pool)
@@ -1349,7 +1402,7 @@ impl UserRepository for PostgresUserRepository {
})
}
async fn get_login_info_by_handle_or_email(
async fn get_login_info_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginInfo>, DbError> {
@@ -1361,7 +1414,7 @@ impl UserRepository for PostgresUserRepository {
email_verified, discord_verified, telegram_verified, signal_verified,
account_type as "account_type!: AccountType"
FROM users
WHERE handle = $1 OR email = $1
WHERE handle = $1 OR did = $1
"#,
identifier
)
@@ -1524,7 +1577,7 @@ impl UserRepository for PostgresUserRepository {
COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as "email_2fa_enabled!"
FROM users u
JOIN user_keys k ON u.id = k.user_id
WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#,
WHERE u.handle = $1 OR u.did = $1"#,
identifier
)
.fetch_optional(&self.pool)

View File

@@ -322,7 +322,9 @@ fn validate_blob_ref(
if let Some(ref accept) = lex_blob.accept {
let mime_type = obj.get("mimeType").and_then(|v| v.as_str()).unwrap_or("");
let matched = accept.iter().any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern));
let matched = accept
.iter()
.any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern));
if !mime_type.is_empty() && !matched {
return Err(LexValidationError::field(
path,

View File

@@ -108,7 +108,7 @@ pub async fn authorize_get(
match state
.repos
.user
.get_login_check_by_handle_or_email(normalized.as_str())
.get_login_check_by_identifier(normalized.as_str())
.await
{
Ok(Some(user)) => {
@@ -401,7 +401,7 @@ pub async fn authorize_post(
let user = match state
.repos
.user
.get_login_info_by_handle_or_email(normalized_username.as_str())
.get_login_info_by_identifier(normalized_username.as_str())
.await
{
Ok(Some(u)) => u,
@@ -410,7 +410,7 @@ pub async fn authorize_post(
&form.password,
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
);
return show_login_error("Invalid handle/email or password.", json_response);
return show_login_error("Invalid identifier or password.", json_response);
}
Err(_) => return show_login_error("An error occurred. Please try again.", json_response),
};
@@ -486,7 +486,7 @@ pub async fn authorize_post(
None => false,
};
if !password_valid {
return show_login_error("Invalid handle/email or password.", json_response);
return show_login_error("Invalid identifier or password.", json_response);
}
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {

View File

@@ -22,7 +22,7 @@ pub async fn check_user_has_passkeys(
let user = state
.repos
.user
.get_login_check_by_handle_or_email(bare_identifier.as_str())
.get_login_check_by_identifier(bare_identifier.as_str())
.await;
let has_passkeys = match user {
@@ -55,7 +55,7 @@ pub async fn check_user_security_status(
let user = state
.repos
.user
.get_login_check_by_handle_or_email(normalized_identifier.as_str())
.get_login_check_by_identifier(normalized_identifier.as_str())
.await;
let (has_passkeys, has_totp, has_password, is_delegated, did): (
@@ -99,7 +99,7 @@ pub async fn check_user_security_status(
#[derive(Debug, Deserialize)]
pub struct PasskeyStartInput {
pub request_uri: String,
pub identifier: String,
pub identifier: Option<String>,
pub delegated_did: Option<String>,
}
@@ -160,14 +160,91 @@ pub async fn passkey_start(
.into_response();
}
match form.identifier.filter(|s| !s.trim().is_empty()) {
Some(identifier) => {
passkey_start_named(
state,
identifier,
form.delegated_did,
request_data,
passkey_start_request_id,
)
.await
}
None => passkey_start_discoverable(state, passkey_start_request_id).await,
}
}
async fn passkey_start_discoverable(
state: AppState,
request_id: RequestId,
) -> Response {
let (rcr, auth_state) = match state.webauthn_config.start_discoverable_authentication() {
Ok(result) => result,
Err(e) => {
tracing::error!(error = %e, "Failed to start discoverable passkey authentication");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "Failed to start authentication."
})),
)
.into_response();
}
};
let state_json = match serde_json::to_string(&auth_state) {
Ok(j) => j,
Err(e) => {
tracing::error!(error = %e, "Failed to serialize authentication state");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
if let Err(e) = state
.repos
.user
.save_discoverable_challenge(request_id.as_str(), &state_json)
.await
{
tracing::error!(error = %e, "Failed to save discoverable authentication state");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
Json(PasskeyStartResponse { options }).into_response()
}
async fn passkey_start_named(
state: AppState,
identifier: String,
delegated_did: Option<String>,
request_data: tranquil_pds::oauth::RequestData,
passkey_start_request_id: RequestId,
) -> Response {
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_username =
NormalizedLoginIdentifier::normalize(&form.identifier, hostname_for_handles);
NormalizedLoginIdentifier::normalize(&identifier, hostname_for_handles);
let user = match state
.repos
.user
.get_login_info_by_handle_or_email(normalized_username.as_str())
.get_login_info_by_identifier(normalized_username.as_str())
.await
{
Ok(Some(u)) => u,
@@ -325,7 +402,7 @@ pub async fn passkey_start(
.into_response();
}
let delegation_from_param = match &form.delegated_did {
let delegation_from_param = match &delegated_did {
Some(delegated_did_str) => match delegated_did_str.parse::<tranquil_types::Did>() {
Ok(delegated_did) if delegated_did != user.did => {
match state
@@ -471,85 +548,6 @@ pub async fn passkey_finish(
.into_response();
}
let did_str = match request_data.did {
Some(d) => d,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "No passkey authentication in progress."
})),
)
.into_response();
}
};
let did: tranquil_types::Did = match did_str.parse() {
Ok(d) => d,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid DID format."
})),
)
.into_response();
}
};
let controller_did: Option<tranquil_types::Did> = request_data
.controller_did
.as_ref()
.and_then(|s| s.parse().ok());
let passkey_owner_did = controller_did.as_ref().unwrap_or(&did);
let auth_state_json = match state
.repos
.user
.load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
.await
{
Ok(Some(s)) => s,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "No passkey authentication in progress or challenge expired."
})),
)
.into_response();
}
Err(e) => {
tracing::error!(error = %e, "Failed to load authentication state");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication =
match serde_json::from_str(&auth_state_json) {
Ok(s) => s,
Err(e) => {
tracing::error!(error = %e, "Failed to deserialize authentication state");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
let credential: webauthn_rs::prelude::PublicKeyCredential =
match serde_json::from_value(form.credential) {
Ok(c) => c,
@@ -566,33 +564,35 @@ pub async fn passkey_finish(
}
};
let auth_result = match state
.webauthn_config
.finish_authentication(&credential, &auth_state)
{
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication");
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Passkey verification failed."
})),
let (did, auth_result) = match request_data.did.clone() {
Some(did) => match passkey_finish_named(&state, did, &request_data, &credential).await {
Ok(result) => result,
Err(response) => return response,
},
None => {
let result = match passkey_finish_discoverable(
&state,
&credential,
&passkey_finish_request_id,
)
.into_response();
.await
{
Ok(result) => result,
Err(response) => return response,
};
if state
.repos
.oauth
.set_authorization_did(&passkey_finish_request_id, &result.0, None)
.await
.is_err()
{
return OAuthError::ServerError("An error occurred.".into()).into_response();
}
result
}
};
if let Err(e) = state
.repos
.user
.delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
.await
{
tracing::warn!(error = %e, "Failed to delete authentication state");
}
if auth_result.needs_update() {
let cred_id_bytes = auth_result.cred_id().as_slice();
match state
@@ -691,6 +691,187 @@ pub async fn passkey_finish(
.into_response()
}
async fn passkey_finish_named(
state: &AppState,
did: tranquil_types::Did,
request_data: &tranquil_pds::oauth::RequestData,
credential: &webauthn_rs::prelude::PublicKeyCredential,
) -> Result<
(
tranquil_types::Did,
webauthn_rs::prelude::AuthenticationResult,
),
Response,
> {
let passkey_owner_did = request_data.controller_did.as_ref().unwrap_or(&did);
let auth_state_json = state
.repos
.user
.load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load authentication state");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
).into_response()
})?
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "No passkey authentication in progress or challenge expired."
})),
).into_response()
})?;
let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication =
serde_json::from_str(&auth_state_json).map_err(|e| {
tracing::error!(error = %e, "Failed to deserialize authentication state");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
).into_response()
})?;
let auth_result = state
.webauthn_config
.finish_authentication(credential, &auth_state)
.map_err(|e| {
tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication");
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Passkey verification failed."
})),
)
.into_response()
})?;
let _ = state
.repos
.user
.delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication)
.await;
Ok((did, auth_result))
}
async fn passkey_finish_discoverable(
state: &AppState,
credential: &webauthn_rs::prelude::PublicKeyCredential,
request_id: &RequestId,
) -> Result<
(
tranquil_types::Did,
webauthn_rs::prelude::AuthenticationResult,
),
Response,
> {
let auth_state_json = state
.repos
.user
.load_discoverable_challenge(request_id.as_str())
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load discoverable authentication state");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
).into_response()
})?
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "No passkey authentication in progress or challenge expired."
})),
).into_response()
})?;
let auth_state: webauthn_rs::prelude::DiscoverableAuthentication =
serde_json::from_str(&auth_state_json).map_err(|e| {
tracing::error!(error = %e, "Failed to deserialize discoverable authentication state");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
).into_response()
})?;
let (_user_uuid, cred_id) = state
.webauthn_config
.identify_discoverable_authentication(credential)
.map_err(|e| {
tracing::warn!(error = %e, "Failed to identify discoverable credential");
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Passkey verification failed."
})),
)
.into_response()
})?;
let stored_passkey = state
.repos
.user
.get_passkey_by_credential_id(cred_id)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to look up passkey by credential ID");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
).into_response()
})?
.ok_or_else(|| {
tracing::warn!("Discoverable credential not found in database");
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Passkey not recognized."
})),
).into_response()
})?;
let discoverable_key: webauthn_rs::prelude::DiscoverableKey =
serde_json::from_slice(&stored_passkey.public_key).map_err(|e| {
tracing::error!(error = %e, "Failed to deserialize stored passkey as DiscoverableKey");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
).into_response()
})?;
let auth_result = state
.webauthn_config
.finish_discoverable_authentication(credential, auth_state, &[discoverable_key])
.map_err(|e| {
tracing::warn!(error = %e, did = %stored_passkey.did, "Failed to verify discoverable passkey authentication");
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Passkey verification failed."
})),
).into_response()
})?;
let _ = state
.repos
.user
.delete_discoverable_challenge(request_id.as_str())
.await;
Ok((stored_passkey.did, auth_result))
}
#[derive(Debug, Deserialize)]
pub struct AuthorizePasskeyQuery {
pub request_uri: String,

View File

@@ -78,6 +78,7 @@ tracing = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
webauthn-rs = { workspace = true }
webauthn-rs-proto = { workspace = true }
zip = { workspace = true }
aws-config = { workspace = true, optional = true }
aws-sdk-s3 = { workspace = true, optional = true }

View File

@@ -1,5 +1,8 @@
use uuid::Uuid;
use webauthn_rs::prelude::*;
use webauthn_rs_proto::{
AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationPolicy,
};
#[derive(Debug, thiserror::Error)]
pub enum WebauthnError {
@@ -57,6 +60,15 @@ impl WebAuthnConfig {
None,
None,
)
.map(|(mut ccr, state)| {
let sel = ccr
.public_key
.authenticator_selection
.get_or_insert_with(AuthenticatorSelectionCriteria::default);
sel.resident_key = Some(ResidentKeyRequirement::Required);
sel.require_resident_key = true;
(ccr, state)
})
.map_err(|e| WebauthnError::RegistrationFailed(e.to_string()))
}
@@ -88,4 +100,49 @@ impl WebAuthnConfig {
.finish_securitykey_authentication(auth, state)
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
}
pub fn start_discoverable_authentication(
&self,
) -> Result<(RequestChallengeResponse, DiscoverableAuthentication), WebauthnError> {
let (mut rcr, state) = self
.webauthn
.start_discoverable_authentication()
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?;
rcr.mediation = None;
rcr.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
let mut state_json = serde_json::to_value(&state)
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?;
let ast = state_json
.get_mut("ast")
.ok_or_else(|| WebauthnError::AuthenticationFailed(
"webauthn-rs DiscoverableAuthentication missing 'ast' field, library version incompatible".into(),
))?;
ast["policy"] = serde_json::json!("discouraged");
let patched: DiscoverableAuthentication = serde_json::from_value(state_json)
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))?;
Ok((rcr, patched))
}
pub fn identify_discoverable_authentication<'a>(
&self,
credential: &'a PublicKeyCredential,
) -> Result<(Uuid, &'a [u8]), WebauthnError> {
self.webauthn
.identify_discoverable_authentication(credential)
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
}
pub fn finish_discoverable_authentication(
&self,
credential: &PublicKeyCredential,
state: DiscoverableAuthentication,
creds: &[DiscoverableKey],
) -> Result<AuthenticationResult, WebauthnError> {
self.webauthn
.finish_discoverable_authentication(credential, state, creds)
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
}
}

View File

@@ -3487,13 +3487,13 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
recv(rx).await
}
async fn get_login_check_by_handle_or_email(
async fn get_login_check_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginCheck>, DbError> {
let (tx, rx) = oneshot::channel();
self.pool.send(MetastoreRequest::User(
UserRequest::GetLoginCheckByHandleOrEmail {
UserRequest::GetLoginCheckByIdentifier {
identifier: identifier.to_owned(),
tx,
},
@@ -3501,13 +3501,13 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
recv(rx).await
}
async fn get_login_info_by_handle_or_email(
async fn get_login_info_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginInfo>, DbError> {
let (tx, rx) = oneshot::channel();
self.pool.send(MetastoreRequest::User(
UserRequest::GetLoginInfoByHandleOrEmail {
UserRequest::GetLoginInfoByIdentifier {
identifier: identifier.to_owned(),
tx,
},
@@ -4233,6 +4233,47 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
recv(rx).await
}
async fn save_discoverable_challenge(
&self,
request_key: &str,
state_json: &str,
) -> Result<Uuid, DbError> {
let (tx, rx) = oneshot::channel();
self.pool.send(MetastoreRequest::User(
UserRequest::SaveDiscoverableChallenge {
request_key: request_key.to_owned(),
state_json: state_json.to_owned(),
tx,
},
))?;
recv(rx).await
}
async fn load_discoverable_challenge(
&self,
request_key: &str,
) -> Result<Option<String>, DbError> {
let (tx, rx) = oneshot::channel();
self.pool.send(MetastoreRequest::User(
UserRequest::LoadDiscoverableChallenge {
request_key: request_key.to_owned(),
tx,
},
))?;
recv(rx).await
}
async fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), DbError> {
let (tx, rx) = oneshot::channel();
self.pool.send(MetastoreRequest::User(
UserRequest::DeleteDiscoverableChallenge {
request_key: request_key.to_owned(),
tx,
},
))?;
recv(rx).await
}
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError> {
let (tx, rx) = oneshot::channel();
self.pool

View File

@@ -996,11 +996,11 @@ pub enum UserRequest {
email: String,
tx: Tx<Option<UserForVerification>>,
},
GetLoginCheckByHandleOrEmail {
GetLoginCheckByIdentifier {
identifier: String,
tx: Tx<Option<UserLoginCheck>>,
},
GetLoginInfoByHandleOrEmail {
GetLoginInfoByIdentifier {
identifier: String,
tx: Tx<Option<UserLoginInfo>>,
},
@@ -1273,6 +1273,19 @@ pub enum UserRequest {
challenge_type: WebauthnChallengeType,
tx: Tx<()>,
},
SaveDiscoverableChallenge {
request_key: String,
state_json: String,
tx: Tx<Uuid>,
},
LoadDiscoverableChallenge {
request_key: String,
tx: Tx<Option<String>>,
},
DeleteDiscoverableChallenge {
request_key: String,
tx: Tx<()>,
},
GetTotpRecord {
did: Did,
tx: Tx<Option<TotpRecord>>,
@@ -1726,8 +1739,8 @@ impl UserRequest {
| Self::GetAnyAdminUserId { .. }
| Self::SearchAccounts { .. }
| Self::GetByEmail { .. }
| Self::GetLoginCheckByHandleOrEmail { .. }
| Self::GetLoginInfoByHandleOrEmail { .. }
| Self::GetLoginCheckByIdentifier { .. }
| Self::GetLoginInfoByIdentifier { .. }
| Self::CheckEmailVerifiedByIdentifier { .. }
| Self::StoreTelegramChatId { .. }
| Self::StoreDiscordUserId { .. }
@@ -1743,7 +1756,10 @@ impl UserRequest {
| Self::CleanupExpiredHandleReservations { .. }
| Self::CheckAndConsumeInviteCode { .. }
| Self::GetPasswordResetInfo { .. }
| Self::ExpirePasswordResetCode { .. } => Routing::Global,
| Self::ExpirePasswordResetCode { .. }
| Self::SaveDiscoverableChallenge { .. }
| Self::LoadDiscoverableChallenge { .. }
| Self::DeleteDiscoverableChallenge { .. } => Routing::Global,
}
}
}
@@ -5066,15 +5082,15 @@ fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserReque
UserRequest::GetByEmail { email, tx } => {
let _ = tx.send(user.get_by_email(&email).map_err(metastore_to_db));
}
UserRequest::GetLoginCheckByHandleOrEmail { identifier, tx } => {
UserRequest::GetLoginCheckByIdentifier { identifier, tx } => {
let _ = tx.send(
user.get_login_check_by_handle_or_email(&identifier)
user.get_login_check_by_identifier(&identifier)
.map_err(metastore_to_db),
);
}
UserRequest::GetLoginInfoByHandleOrEmail { identifier, tx } => {
UserRequest::GetLoginInfoByIdentifier { identifier, tx } => {
let _ = tx.send(
user.get_login_info_by_handle_or_email(&identifier)
user.get_login_info_by_identifier(&identifier)
.map_err(metastore_to_db),
);
}
@@ -5434,6 +5450,28 @@ fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserReque
.map_err(metastore_to_db),
);
}
UserRequest::SaveDiscoverableChallenge {
request_key,
state_json,
tx,
} => {
let _ = tx.send(
user.save_discoverable_challenge(&request_key, &state_json)
.map_err(metastore_to_db),
);
}
UserRequest::LoadDiscoverableChallenge { request_key, tx } => {
let _ = tx.send(
user.load_discoverable_challenge(&request_key)
.map_err(metastore_to_db),
);
}
UserRequest::DeleteDiscoverableChallenge { request_key, tx } => {
let _ = tx.send(
user.delete_discoverable_challenge(&request_key)
.map_err(metastore_to_db),
);
}
UserRequest::GetTotpRecord { did, tx } => {
let _ = tx.send(user.get_totp_record(&did).map_err(metastore_to_db));
}

View File

@@ -125,15 +125,9 @@ impl UserOps {
}
fn load_by_identifier(&self, identifier: &str) -> Result<Option<UserValue>, MetastoreError> {
match identifier.contains('@') {
true => self.load_by_email(identifier).and_then(|opt| match opt {
Some(v) => Ok(Some(v)),
None => self.load_by_handle(identifier),
}),
false => self.load_by_handle(identifier).and_then(|opt| match opt {
Some(v) => Ok(Some(v)),
None => self.load_by_email(identifier),
}),
match identifier.starts_with("did:") {
true => self.load_user_by_did(identifier),
false => self.load_by_handle(identifier),
}
}
@@ -472,7 +466,7 @@ impl UserOps {
.transpose()
}
pub fn get_login_check_by_handle_or_email(
pub fn get_login_check_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginCheck>, MetastoreError> {
@@ -487,7 +481,7 @@ impl UserOps {
.transpose()
}
pub fn get_login_info_by_handle_or_email(
pub fn get_login_info_by_identifier(
&self,
identifier: &str,
) -> Result<Option<UserLoginInfo>, MetastoreError> {
@@ -1511,6 +1505,57 @@ impl UserOps {
.map_err(MetastoreError::Fjall)
}
const DISCOVERABLE_CHALLENGE_TYPE: u8 = 2;
pub fn save_discoverable_challenge(
&self,
request_key: &str,
state_json: &str,
) -> Result<Uuid, MetastoreError> {
let key_hash = UserHash::from_did(request_key);
let id = Uuid::new_v4();
let now_ms = Utc::now().timestamp_millis();
let value = WebauthnChallengeValue {
id,
challenge_type: Self::DISCOVERABLE_CHALLENGE_TYPE,
state_json: state_json.to_owned(),
created_at_ms: now_ms,
};
let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE);
self.auth
.insert(key.as_slice(), value.serialize_with_ttl())
.map_err(MetastoreError::Fjall)?;
Ok(id)
}
pub fn load_discoverable_challenge(
&self,
request_key: &str,
) -> Result<Option<String>, MetastoreError> {
let key_hash = UserHash::from_did(request_key);
let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE);
let val: Option<WebauthnChallengeValue> = point_lookup(
&self.auth,
key.as_slice(),
WebauthnChallengeValue::deserialize,
"corrupt webauthn challenge",
)?;
Ok(val.map(|v| v.state_json))
}
pub fn delete_discoverable_challenge(&self, request_key: &str) -> Result<(), MetastoreError> {
let key_hash = UserHash::from_did(request_key);
let key = webauthn_challenge_key(key_hash, Self::DISCOVERABLE_CHALLENGE_TYPE);
self.auth
.remove(key.as_slice())
.map_err(MetastoreError::Fjall)
}
pub fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, MetastoreError> {
let user_hash = self.resolve_hash(did.as_str());
let key = totp_key(user_hash);

View File

@@ -1,7 +1,7 @@
<script lang="ts" module>
const EXAMPLE_HANDLES = [
"nel.pet",
"lewis.moe",
"oyster.cafe",
"llaama.bsky.social",
"debugman.wizardry.systems",
"nonbinary.computer",

View File

@@ -15,9 +15,10 @@
session: Session
hasPassword: boolean
onPasskeysChanged?: (count: number) => void
onReauthRequired: (methods: string[], retryAction: () => Promise<void>) => void
}
let { session, hasPassword, onPasskeysChanged }: Props = $props()
let { session, hasPassword, onPasskeysChanged, onReauthRequired }: Props = $props()
interface Passkey {
id: string
@@ -81,6 +82,14 @@
}
}
function handleReauthError(e: unknown, fallback: string, retryAction: () => Promise<void>) {
if (e instanceof ApiError && e.error === 'ReauthRequired') {
onReauthRequired(e.reauthMethods || ['password'], retryAction)
} else {
toast.error(e instanceof ApiError ? e.message : fallback)
}
}
async function handleDeletePasskey(id: string) {
const passkey = passkeys.find(p => p.id === id)
if (!confirm($_('security.deletePasskeyConfirm', { values: { name: passkey?.friendlyName || 'this passkey' } }))) return
@@ -89,7 +98,7 @@
await loadPasskeys()
toast.success($_('security.passkeyDeleted'))
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
handleReauthError(e, 'Failed to delete passkey', () => handleDeletePasskey(id))
}
}

View File

@@ -282,6 +282,7 @@
{session}
{hasPassword}
onPasskeysChanged={(count) => passkeyCount = count}
onReauthRequired={handleReauthRequired}
/>
<TotpSection

View File

@@ -522,6 +522,7 @@
"passkeyHintNotAvailable": "No passkey registered",
"passwordPlaceholder": "Password",
"usePasskey": "Use passkey",
"passkeyNotAllowed": "No passkey available for this site",
"orUseCredentials": "or",
"verificationResent": "Verification code sent"
},

View File

@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "Ei pääsyavainta",
"passwordPlaceholder": "Salasana",
"usePasskey": "Käytä pääsyavainta",
"passkeyNotAllowed": "Pääsyavainta ei ole saatavilla tälle sivustolle",
"orUseCredentials": "tai",
"verificationResent": "Vahvistuskoodi lähetetty"
},

View File

@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "パスキーなし",
"passwordPlaceholder": "パスワード",
"usePasskey": "パスキーを使用",
"passkeyNotAllowed": "このサイトで利用可能なパスキーがありません",
"orUseCredentials": "または",
"verificationResent": "確認コードを送信しました"
},

View File

@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "패스키 없음",
"passwordPlaceholder": "비밀번호",
"usePasskey": "패스키 사용",
"passkeyNotAllowed": "이 사이트에 사용 가능한 패스키가 없습니다",
"orUseCredentials": "또는",
"verificationResent": "인증 코드 전송됨"
},

View File

@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "Ingen nyckel registrerad",
"passwordPlaceholder": "Lösenord",
"usePasskey": "Använd nyckel",
"passkeyNotAllowed": "Ingen nyckel tillgänglig för denna webbplats",
"orUseCredentials": "eller",
"verificationResent": "Verifieringskod skickad"
},

View File

@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "未注册通行密钥",
"passwordPlaceholder": "密码",
"usePasskey": "使用通行密钥",
"passkeyNotAllowed": "此站点没有可用的通行密钥",
"orUseCredentials": "或",
"verificationResent": "验证码已发送"
},

View File

@@ -38,13 +38,8 @@
let submitting = $state(false)
let error = $state<string | null>(null)
let verificationResent = $state(false)
let hasPasskeys = $state(false)
let hasTotp = $state(false)
let hasPassword = $state(true)
let isDelegated = $state(false)
let userDid = $state<string | null>(null)
let checkingSecurityStatus = $state(false)
let securityStatusChecked = $state(false)
let passkeySupported = $state(false)
let clientName = $state<string | null>(null)
@@ -160,31 +155,27 @@
let checkTimeout: ReturnType<typeof setTimeout> | null = null
let checkingDelegation = false
$effect(() => {
if (checkTimeout) {
clearTimeout(checkTimeout)
}
hasPasskeys = false
hasTotp = false
securityStatusChecked = false
isDelegated = false
if (username.length >= 3) {
checkTimeout = setTimeout(() => checkUserSecurityStatus(), 500)
checkTimeout = setTimeout(() => checkDelegationStatus(), 500)
}
})
async function checkUserSecurityStatus() {
if (!username || checkingSecurityStatus) return
checkingSecurityStatus = true
async function checkDelegationStatus() {
if (!username || checkingDelegation) return
checkingDelegation = true
try {
const response = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(username)}`)
if (response.ok) {
const data = await response.json()
hasPasskeys = passkeySupported && data.hasPasskeys === true
hasTotp = data.hasTotp === true
hasPassword = data.hasPassword !== false
isDelegated = data.isDelegated === true
userDid = data.did || null
securityStatusChecked = true
if (isDelegated && data.did) {
const requestUri = getRequestUri()
@@ -198,19 +189,16 @@
}
}
} catch {
hasPasskeys = false
hasTotp = false
hasPassword = true
isDelegated = false
} finally {
checkingSecurityStatus = false
checkingDelegation = false
}
}
async function handlePasskeyLogin() {
const requestUri = getRequestUri()
if (!requestUri || !username) {
if (!requestUri) {
error = $_('common.error')
return
}
@@ -220,16 +208,18 @@
verificationResent = false
try {
const body: Record<string, string> = { request_uri: requestUri }
if (username.trim()) {
body.identifier = username
}
const startResponse = await fetch('/oauth/passkey/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
identifier: username
})
body: JSON.stringify(body)
})
if (!startResponse.ok) {
@@ -306,9 +296,9 @@
} catch (e) {
console.error('Passkey login error:', e)
if (e instanceof DOMException && e.name === 'NotAllowedError') {
error = $_('common.error')
error = $_('oauth.login.passkeyNotAllowed')
} else {
error = `${$_('common.error')}: ${e instanceof Error ? e.message : String(e)}`
error = e instanceof Error ? e.message : String(e)
}
submitting = false
}
@@ -439,17 +429,15 @@
</div>
{/if}
{#if passkeySupported && username.length >= 3}
<div class="auth-methods" class:single-method={!hasPassword}>
{#if passkeySupported}
<div class="auth-methods">
<div class="passkey-method">
<h3>{$_('oauth.login.signInWithPasskey')}</h3>
<button
type="button"
style="width: 100%"
class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
onclick={handlePasskeyLogin}
disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')}
disabled={submitting}
>
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
@@ -459,80 +447,72 @@
<span class="passkey-text">
{#if submitting}
{$_('oauth.login.authenticating')}
{:else if checkingSecurityStatus || !securityStatusChecked}
{$_('oauth.login.checkingPasskey')}
{:else if hasPasskeys}
{$_('oauth.login.usePasskey')}
{:else}
{$_('oauth.login.passkeyNotSetUp')}
{$_('oauth.login.usePasskey')}
{/if}
</span>
</button>
</div>
{#if hasPassword}
<div class="method-divider">
<span>{$_('oauth.login.orUsePassword')}</span>
<div class="method-divider">
<span>{$_('oauth.login.orUsePassword')}</span>
</div>
<div class="password-method">
<h3>{$_('oauth.login.password')}</h3>
<div class="field">
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
placeholder={$_('oauth.login.passwordPlaceholder')}
/>
</div>
<div class="password-method">
<h3>{$_('oauth.login.password')}</h3>
<div class="field">
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
placeholder={$_('oauth.login.passwordPlaceholder')}
/>
</div>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
</div>
</div>
{:else}
{#if hasPassword || !securityStatusChecked}
<div>
<label for="password">{$_('oauth.login.password')}</label>
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
/>
</div>
<div>
<label for="password">{$_('oauth.login.password')}</label>
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
/>
</div>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
</form>