mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
444dc2b7d4 | ||
|
|
e2d05a44a8 | ||
|
|
669bd3c581 | ||
|
|
30ba55bd4d | ||
|
|
d4e93d3e84 | ||
|
|
9def9fbef6 | ||
|
|
7c82ca1102 | ||
|
|
fa3fd0027c | ||
|
|
83c1710b47 | ||
|
|
7e1b934637 | ||
|
|
48cf56e732 | ||
|
|
33a118d12a | ||
|
|
6b52de6354 | ||
|
|
01f53a168d | ||
|
|
59840af1bf | ||
|
|
336b8b7dcd | ||
|
|
2df37450ae | ||
|
|
48d0034239 | ||
|
|
2fc97f4544 | ||
|
|
677ef8491e | ||
|
|
2bf3a610f4 | ||
|
|
6a6119ed1e | ||
|
|
86d92564c0 | ||
|
|
931327d1dc | ||
|
|
2edd0f188f | ||
|
|
858a2d9e2b | ||
|
|
cb55e62bd9 | ||
|
|
841fb7af41 | ||
|
|
ed579aaeec | ||
|
|
0808bf680f | ||
|
|
e75ed31a4c | ||
|
|
b6e3fa47a8 | ||
|
|
690cca6edb | ||
|
|
f28937d86f | ||
|
|
6b04ad25b1 | ||
|
|
63980fca11 | ||
|
|
0d92584431 | ||
|
|
0e2b7a1a2b | ||
|
|
bbe55397c3 | ||
|
|
0b5514f663 | ||
|
|
380995cc19 | ||
|
|
e3522ec7be | ||
|
|
56bc0e5612 | ||
|
|
d501a1ea68 | ||
|
|
6e3739bb9b | ||
|
|
b4f789a5ba | ||
|
|
49761959b1 | ||
|
|
34e03c9732 | ||
|
|
58d6e2d4bf | ||
|
|
4766ce7974 | ||
|
|
9e6aa67834 | ||
|
|
57832208d5 | ||
|
|
b35c85fc54 | ||
|
|
7f58fba66d | ||
|
|
5b35d6da7b | ||
|
|
53b9f14f35 | ||
|
|
bb83a6c042 | ||
|
|
e7125e4e62 |
@@ -33,9 +33,9 @@ Fluxer is a free and open source instant messaging and VoIP chat app built for f
|
||||
|
||||
| Windows | macOS | Linux | Android | iOS |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [APK][android-apk] | [TestFlight][ios-testflight] |
|
||||
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [Obtainium][obtainium] | |
|
||||
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | | |
|
||||
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [Google Play (beta)][android-play] | [TestFlight][ios-testflight] |
|
||||
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [APK (beta)][android-apk] | |
|
||||
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | [Obtainium (beta)][obtainium] | |
|
||||
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
|
||||
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
|
||||
| | | [AppImage (x64)][linux-appimage-x64] | | |
|
||||
@@ -168,6 +168,7 @@ endorsement rights.
|
||||
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||
[flatpak-canary-ref]: https://pkgs.fluxer.com/flatpak/fluxer-canary.flatpakref
|
||||
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
|
||||
[android-play]: https://play.google.com/store/apps/details?id=com.fluxer
|
||||
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
|
||||
[obtainium]: https://obtainium.imranr.dev/
|
||||
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
|
||||
|
||||
@@ -10526,6 +10526,7 @@
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
|
||||
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
@@ -10955,6 +10956,7 @@
|
||||
"voice_noise_suppression",
|
||||
"push_service_delivery",
|
||||
"domain_migration",
|
||||
"altcha_captcha",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
@@ -11097,6 +11099,10 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
|
||||
},
|
||||
"altcha_captcha": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
@@ -15190,6 +15196,27 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"AltchaCaptchaConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"anonymous_enabled": {"type": "boolean"},
|
||||
"cost": {"type": "integer", "minimum": 1000, "maximum": 100000},
|
||||
"max_counter": {"type": "integer", "minimum": 100, "maximum": 1000000}
|
||||
}
|
||||
},
|
||||
"DomainMigrationConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15225,7 +15252,8 @@
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"relay_consent_accepted": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigUpdateRequest": {
|
||||
@@ -15293,6 +15321,48 @@
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AltchaCaptchaConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {
|
||||
"default": "altcha-captcha-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"anonymous_enabled": {"default": false, "type": "boolean"},
|
||||
"cost": {"default": 5000, "type": "integer", "minimum": 1000, "maximum": 100000},
|
||||
"max_counter": {"default": 10000, "type": "integer", "minimum": 100, "maximum": 1000000}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids",
|
||||
"anonymous_enabled",
|
||||
"cost",
|
||||
"max_counter"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15357,7 +15427,16 @@
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"relay_consent_accepted": {"default": false, "type": "boolean"},
|
||||
"relay_consent_accepted_at": {
|
||||
"default": null,
|
||||
"nullable": true,
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
},
|
||||
"relay_consent_accepted_by": {"default": null, "nullable": true, "type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
@@ -15365,7 +15444,10 @@
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
"excluded_user_ids",
|
||||
"relay_consent_accepted",
|
||||
"relay_consent_accepted_at",
|
||||
"relay_consent_accepted_by"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -15711,9 +15793,10 @@
|
||||
"id": {"type": "string", "description": "The credential ID"},
|
||||
"name": {"type": "string", "description": "User-assigned name for the credential"},
|
||||
"created_at": {"type": "string", "description": "When the credential was registered"},
|
||||
"last_used_at": {"nullable": true, "description": "When the credential was last used", "type": "string"}
|
||||
"last_used_at": {"nullable": true, "description": "When the credential was last used", "type": "string"},
|
||||
"rp_id": {"type": "string", "description": "Relying party ID the passkey belongs to"}
|
||||
},
|
||||
"required": ["id", "name", "created_at", "last_used_at"],
|
||||
"required": ["id", "name", "created_at", "last_used_at", "rp_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceServerAdminResponse": {
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub altcha_captcha: AltchaCaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
}
|
||||
|
||||
@@ -453,6 +455,9 @@ impl VoiceE2eeScope {
|
||||
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
|
||||
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||
pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1";
|
||||
pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=100_000;
|
||||
pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=1_000_000;
|
||||
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
@@ -552,6 +557,9 @@ pub struct PushServiceDeliveryConfigResponse {
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub relay_consent_accepted: bool,
|
||||
pub relay_consent_accepted_at: Option<String>,
|
||||
pub relay_consent_accepted_by: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for PushServiceDeliveryConfigResponse {
|
||||
@@ -563,6 +571,9 @@ impl Default for PushServiceDeliveryConfigResponse {
|
||||
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: None,
|
||||
relay_consent_accepted_by: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -579,6 +590,8 @@ pub struct PushServiceDeliveryConfigUpdateRequest {
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub relay_consent_accepted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -627,6 +640,56 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
pub standalone_forwarding: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct AltchaCaptchaConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub anonymous_enabled: bool,
|
||||
pub cost: u32,
|
||||
pub max_counter: u32,
|
||||
}
|
||||
|
||||
impl Default for AltchaCaptchaConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
anonymous_enabled: false,
|
||||
cost: 5_000,
|
||||
max_counter: 10_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct AltchaCaptchaConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anonymous_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_counter: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
@@ -747,6 +810,8 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
@@ -1086,17 +1151,24 @@ mod tests {
|
||||
.expect("default noise config");
|
||||
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
|
||||
.expect("default domain migration config");
|
||||
let altcha_captcha = serde_json::from_value::<AltchaCaptchaConfigResponse>(json!({}))
|
||||
.expect("default altcha captcha config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let noise = serde_json::to_value(noise).expect("serializable noise config");
|
||||
let domain_migration =
|
||||
serde_json::to_value(domain_migration).expect("serializable domain migration config");
|
||||
let altcha_captcha =
|
||||
serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
|
||||
serde_json::from_value(noise.clone()).expect("generated noise config contract");
|
||||
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
|
||||
serde_json::from_value(domain_migration.clone())
|
||||
.expect("generated domain migration config contract");
|
||||
let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse =
|
||||
serde_json::from_value(altcha_captcha.clone())
|
||||
.expect("generated altcha captcha config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
assert_eq!(
|
||||
@@ -1108,6 +1180,11 @@ mod tests {
|
||||
.expect("serializable generated domain migration config"),
|
||||
domain_migration
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_altcha_captcha)
|
||||
.expect("serializable generated altcha captcha config"),
|
||||
altcha_captcha
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
.expect("serializable generated delivery config"),
|
||||
@@ -1116,6 +1193,7 @@ mod tests {
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("AltchaCaptchaConfigResponse", altcha_captcha),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
|
||||
@@ -4,24 +4,25 @@ use crate::{
|
||||
api::{
|
||||
client::AdminApiClient,
|
||||
types::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, DomainMigrationConfigUpdateRequest,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode,
|
||||
PushServiceDeliveryConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
|
||||
VoiceNoiseSuppressionGuildOverride,
|
||||
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
|
||||
AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest,
|
||||
AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest,
|
||||
AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest,
|
||||
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
|
||||
PremiumMode, PushServiceDeliveryConfigUpdateRequest, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
|
||||
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -216,6 +217,10 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_altcha_captcha" => match build_altcha_captcha_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -680,6 +685,9 @@ fn build_push_service_delivery_update(
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
relay_consent_accepted: Some(
|
||||
form.bool_value("push_service_delivery_relay_consent_accepted"),
|
||||
),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -725,6 +733,50 @@ fn build_domain_migration_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_altcha_captcha_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
altcha_captcha: Some(AltchaCaptchaConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("altcha_captcha_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_ascii_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
anonymous_enabled: Some(form.bool_value("altcha_captcha_anonymous_enabled")),
|
||||
cost: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_cost",
|
||||
"Cost",
|
||||
*ALTCHA_CAPTCHA_COST_RANGE.start(),
|
||||
*ALTCHA_CAPTCHA_COST_RANGE.end(),
|
||||
)?,
|
||||
max_counter: parse_form_number(
|
||||
form,
|
||||
"altcha_captcha_max_counter",
|
||||
"Maximum counter",
|
||||
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start(),
|
||||
*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end(),
|
||||
)?,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -1731,6 +1783,94 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_push_service_delivery_update_reads_the_relay_consent_checkbox() {
|
||||
let unchecked = MultiValueForm::parse(b"_csrf=token");
|
||||
assert_eq!(
|
||||
build_push_service_delivery_update(&unchecked)
|
||||
.expect("valid form")
|
||||
.push_service_delivery
|
||||
.expect("push service delivery update")
|
||||
.relay_consent_accepted,
|
||||
Some(false)
|
||||
);
|
||||
|
||||
let checked =
|
||||
MultiValueForm::parse(b"_csrf=token&push_service_delivery_relay_consent_accepted=true");
|
||||
assert_eq!(
|
||||
build_push_service_delivery_update(&checked)
|
||||
.expect("valid form")
|
||||
.push_service_delivery
|
||||
.expect("push service delivery update")
|
||||
.relay_consent_accepted,
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_captcha_update_reads_the_rollout_and_difficulty_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"altcha_captcha_enabled=true&altcha_captcha_rollout_basis_points=%20500%20&altcha_captcha_rollout_salt=%20altcha-captcha-v2%20&altcha_captcha_included_user_ids=1500000000000000001&altcha_captcha_excluded_user_ids=1500000000000000002&altcha_captcha_anonymous_enabled=true&altcha_captcha_cost=2000&altcha_captcha_max_counter=%20400%20",
|
||||
);
|
||||
let update = build_altcha_captcha_update(&form)
|
||||
.expect("valid form")
|
||||
.altcha_captcha
|
||||
.expect("altcha captcha update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("altcha-captcha-v2".to_owned()));
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
assert_eq!(update.anonymous_enabled, Some(true));
|
||||
assert_eq!(update.cost, Some(2000));
|
||||
assert_eq!(update.max_counter, Some(400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_captcha_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_altcha_captcha_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"altcha_captcha": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"anonymous_enabled": false,
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_altcha_captcha_update_rejects_difficulty_outside_the_supported_range() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"altcha_captcha_cost=999",
|
||||
"Cost must be a whole number between 1000 and 100000",
|
||||
),
|
||||
(
|
||||
"altcha_captcha_max_counter=1000001",
|
||||
"Maximum counter must be a whole number between 100 and 1000000",
|
||||
),
|
||||
(
|
||||
"altcha_captcha_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_altcha_captcha_update(&form).expect_err("invalid field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
|
||||
PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
|
||||
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_DEFAULT_SALT, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
|
||||
AltchaCaptchaConfigResponse, AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT,
|
||||
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, NoiseSuppressionBackend,
|
||||
PUSH_SERVICE_DELIVERY_DEFAULT_SALT, PendingRegistrationResponse,
|
||||
PushServiceDeliveryConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -151,6 +153,7 @@ pub fn instance_config_page(
|
||||
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
|
||||
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
|
||||
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||
(altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1191,6 +1194,14 @@ fn push_service_delivery_section(
|
||||
};
|
||||
let included_user_ids = push_service_delivery.included_user_ids.join("\n");
|
||||
let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n");
|
||||
let relay_consent_stamp = match (
|
||||
push_service_delivery.relay_consent_accepted_at.as_deref(),
|
||||
push_service_delivery.relay_consent_accepted_by.as_deref(),
|
||||
) {
|
||||
(Some(at), Some(by)) => Some(format!("Accepted {at} by user {by}")),
|
||||
(Some(at), None) => Some(format!("Accepted {at}")),
|
||||
_ => None,
|
||||
};
|
||||
section_card_with_description(
|
||||
"Push Service Delivery",
|
||||
"Routes push notification delivery for the selected accounts through the push service. \
|
||||
@@ -1219,6 +1230,28 @@ fn push_service_delivery_section(
|
||||
effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Managed relay consent" }
|
||||
(checkbox(
|
||||
"push_service_delivery_relay_consent_accepted",
|
||||
"true",
|
||||
"Accept the push relay supplemental privacy notice",
|
||||
push_service_delivery.relay_consent_accepted,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Required only for the official mobile apps, whose notifications travel \
|
||||
through Fluxer's relay to Apple and Google. Until this is accepted those \
|
||||
notifications are dropped. Self-hosted UnifiedPush and ntfy endpoints \
|
||||
never reach the relay and are unaffected. "
|
||||
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||
"Read the notice"
|
||||
}
|
||||
}
|
||||
@if let Some(stamp) = relay_consent_stamp {
|
||||
p class="text-xs text-neutral-500" { (stamp) }
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"push_service_delivery_rollout_basis_points",
|
||||
@@ -1421,6 +1454,145 @@ fn domain_migration_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn altcha_captcha_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
altcha_captcha: &AltchaCaptchaConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if altcha_captcha.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = altcha_captcha.included_user_ids.join("\n");
|
||||
let excluded_user_ids = altcha_captcha.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"ALTCHA Captcha",
|
||||
"Replaces the configured captcha provider with an ALTCHA proof-of-work check for the \
|
||||
selected requesters. The API issues and verifies every challenge itself, so no third \
|
||||
party is involved. Requests only need a captcha where one is already required, so this \
|
||||
does nothing while captcha is off for the instance.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_altcha_captcha"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (altcha_captcha.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"altcha_captcha_enabled",
|
||||
"true",
|
||||
"Serve ALTCHA to the selected requesters",
|
||||
altcha_captcha.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked every \
|
||||
requester gets the configured provider and ALTCHA answers are rejected."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Logged-out requests" }
|
||||
(checkbox(
|
||||
"altcha_captcha_anonymous_enabled",
|
||||
"true",
|
||||
"Serve ALTCHA to logged-out requests",
|
||||
altcha_captcha.anonymous_enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Covers registration, login and password reset. These requests have no \
|
||||
account to bucket, so this switch applies to all of them at once."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"altcha_captcha_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&altcha_captcha.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of logged-in users bucketed into ALTCHA, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"altcha_captcha_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&altcha_captcha.rollout_salt,
|
||||
ALTCHA_CAPTCHA_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"altcha_captcha_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
altcha_captcha.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users get ALTCHA \
|
||||
regardless of the percentage above. Invalid entries prevent the save."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"altcha_captcha_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
altcha_captcha.excluded_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the percentage."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Difficulty" }
|
||||
(number_field(
|
||||
"altcha_captcha_cost",
|
||||
"Cost (PBKDF2 iterations per attempt)",
|
||||
&altcha_captcha.cost.to_string(),
|
||||
Some(*ALTCHA_CAPTCHA_COST_RANGE.start()),
|
||||
Some(*ALTCHA_CAPTCHA_COST_RANGE.end()),
|
||||
"1",
|
||||
Some("The API spends one attempt at this cost to issue each challenge."),
|
||||
))
|
||||
(number_field(
|
||||
"altcha_captcha_max_counter",
|
||||
"Maximum counter",
|
||||
&altcha_captcha.max_counter.to_string(),
|
||||
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start()),
|
||||
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end()),
|
||||
"1",
|
||||
Some("Each challenge hides its answer between half this value and this value. The client tries counters from 0 until it finds it, so solve time grows with cost times this value. At the defaults a recent laptop takes about 3 seconds."),
|
||||
))
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save ALTCHA Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -2086,6 +2258,29 @@ mod tests {
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_service_delivery_section_shows_the_relay_consent_toggle() {
|
||||
let accepted = PushServiceDeliveryConfigResponse {
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
|
||||
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
|
||||
..PushServiceDeliveryConfigResponse::default()
|
||||
};
|
||||
let markup = push_service_delivery_section("/admin", "csrf", &accepted).into_string();
|
||||
assert!(markup.contains("name=\"push_service_delivery_relay_consent_accepted\""));
|
||||
assert!(markup.contains("https://fluxer.com/push-relay"));
|
||||
assert!(markup.contains("Accepted 2026-09-27T10:11:12.000Z by user 1130650140672000000"));
|
||||
|
||||
let unaccepted = push_service_delivery_section(
|
||||
"/admin",
|
||||
"csrf",
|
||||
&PushServiceDeliveryConfigResponse::default(),
|
||||
)
|
||||
.into_string();
|
||||
assert!(unaccepted.contains("name=\"push_service_delivery_relay_consent_accepted\""));
|
||||
assert!(!unaccepted.contains("Accepted "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
|
||||
@@ -415,7 +415,10 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "push-service-delivery-v1",
|
||||
"included_user_ids": ["1500000000000000002"],
|
||||
"excluded_user_ids": []
|
||||
"excluded_user_ids": [],
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
"relay_consent_accepted_by": "1130650140672000000"
|
||||
},
|
||||
"domain_migration": {
|
||||
"enabled": true,
|
||||
@@ -428,6 +431,18 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"anonymous_rollout_basis_points": 100,
|
||||
"standalone_forwarding": true
|
||||
},
|
||||
"altcha_captcha": {
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 500,
|
||||
"rollout_salt": "altcha-captcha-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": ["1500000000000000003"],
|
||||
"anonymous_enabled": true,
|
||||
"cost": 5000,
|
||||
"max_counter": 10000,
|
||||
"future_altcha_knob": "argon2id"
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
@@ -564,6 +579,12 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
|
||||
assert!(resp.domain_migration.standalone_forwarding);
|
||||
assert!(resp.push_service_delivery.relay_consent_accepted);
|
||||
assert!(resp.altcha_captcha.enabled);
|
||||
assert_eq!(resp.altcha_captcha.config_version, 3);
|
||||
assert!(resp.altcha_captcha.anonymous_enabled);
|
||||
assert_eq!(resp.altcha_captcha.excluded_user_ids.len(), 1);
|
||||
assert_eq!(resp.altcha_captcha.max_counter, 10000);
|
||||
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
|
||||
assert!(resp.policy.single_community_guild_id.is_none());
|
||||
assert_eq!(resp.policy.services.gif_enabled, Some(true));
|
||||
@@ -596,6 +617,71 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_push_service_delivery_relay_consent() {
|
||||
let accepted: types::PushServiceDeliveryConfigResponse = serde_json::from_str(
|
||||
r#"{
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "push-service-delivery-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
"relay_consent_accepted_by": "1130650140672000000"
|
||||
}"#,
|
||||
)
|
||||
.expect("an accepted relay consent must deserialize");
|
||||
|
||||
assert!(accepted.relay_consent_accepted);
|
||||
assert_eq!(
|
||||
accepted.relay_consent_accepted_at.as_deref(),
|
||||
Some("2026-09-27T10:11:12.000Z")
|
||||
);
|
||||
assert_eq!(
|
||||
accepted.relay_consent_accepted_by.as_deref(),
|
||||
Some("1130650140672000000")
|
||||
);
|
||||
|
||||
let legacy: types::PushServiceDeliveryConfigResponse = serde_json::from_str(
|
||||
r#"{
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "push-service-delivery-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
}"#,
|
||||
)
|
||||
.expect("a response written before relay consent must still deserialize");
|
||||
|
||||
assert!(!legacy.relay_consent_accepted);
|
||||
assert!(legacy.relay_consent_accepted_at.is_none());
|
||||
assert!(legacy.relay_consent_accepted_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_push_service_delivery_update_omits_an_unset_relay_consent() {
|
||||
let without = types::PushServiceDeliveryConfigUpdateRequest {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&without).unwrap(),
|
||||
serde_json::json!({"enabled": true})
|
||||
);
|
||||
|
||||
let with = types::PushServiceDeliveryConfigUpdateRequest {
|
||||
relay_consent_accepted: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&with).unwrap(),
|
||||
serde_json::json!({"relay_consent_accepted": true})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_search_reports_response() {
|
||||
let json = r#"{
|
||||
@@ -870,7 +956,8 @@ fn deserialize_webauthn_credentials_response() {
|
||||
"id": "credential-a",
|
||||
"name": "YubiKey",
|
||||
"created_at": "2026-05-26T12:00:00.000Z",
|
||||
"last_used_at": null
|
||||
"last_used_at": null,
|
||||
"rp_id": "fluxer.com"
|
||||
},
|
||||
{
|
||||
"id": "credential-b",
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@simplewebauthn/server": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@vvo/tzdb": "catalog:",
|
||||
"altcha-lib": "catalog:",
|
||||
"archiver": "catalog:",
|
||||
"argon2": "catalog:",
|
||||
"bowser": "catalog:",
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/logger": "workspace:*",
|
||||
"itty-time": "catalog:"
|
||||
"altcha-lib": "catalog:",
|
||||
"itty-time": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface VerifyCaptchaParams {
|
||||
remoteIp?: string;
|
||||
}
|
||||
|
||||
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'test' | 'unavailable';
|
||||
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable';
|
||||
|
||||
export interface ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {createChallenge, randomInt, verifySolution} from 'altcha-lib';
|
||||
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
import {ms} from 'itty-time';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
|
||||
const ALTCHA_CHALLENGE_TTL_MS = ms('10 minutes');
|
||||
const ALTCHA_MAX_TOKEN_LENGTH = 4096;
|
||||
const HEX_PATTERN = /^[0-9a-f]+$/u;
|
||||
|
||||
const AltchaPayloadSchema = z.object({
|
||||
challenge: z.object({
|
||||
parameters: z.looseObject({
|
||||
algorithm: z.literal(ALTCHA_ALGORITHM),
|
||||
nonce: z.string().regex(HEX_PATTERN),
|
||||
salt: z.string().regex(HEX_PATTERN),
|
||||
cost: z.number().int().positive(),
|
||||
keyLength: z.number().int().positive(),
|
||||
keyPrefix: z.string().regex(HEX_PATTERN),
|
||||
keySignature: z.string().regex(HEX_PATTERN),
|
||||
expiresAt: z.number().int().positive(),
|
||||
}),
|
||||
signature: z.string().regex(HEX_PATTERN),
|
||||
}),
|
||||
solution: z.object({
|
||||
counter: z.number().int().min(0),
|
||||
derivedKey: z.string().regex(HEX_PATTERN),
|
||||
time: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
type AltchaPayload = z.infer<typeof AltchaPayloadSchema>;
|
||||
|
||||
export interface AltchaProviderOptions {
|
||||
hmacSignatureSecret: string;
|
||||
hmacKeySignatureSecret: string;
|
||||
cost: number;
|
||||
maxCounter: number;
|
||||
claimChallenge: (signature: string, ttlSeconds: number) => Promise<boolean>;
|
||||
logger?: LoggerInterface;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function decodePayload(token: string): AltchaPayload | null {
|
||||
if (token.length > ALTCHA_MAX_TOKEN_LENGTH) return null;
|
||||
try {
|
||||
const parsed = AltchaPayloadSchema.safeParse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class AltchaProvider implements ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'altcha';
|
||||
private readonly options: AltchaProviderOptions;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: AltchaProviderOptions) {
|
||||
this.options = options;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async createChallenge(): Promise<Challenge> {
|
||||
const {cost, maxCounter, hmacSignatureSecret, hmacKeySignatureSecret} = this.options;
|
||||
return await createChallenge({
|
||||
algorithm: ALTCHA_ALGORITHM,
|
||||
cost,
|
||||
counter: randomInt(maxCounter, Math.ceil(maxCounter / 2)),
|
||||
deriveKey,
|
||||
expiresAt: new Date(this.now() + ALTCHA_CHALLENGE_TTL_MS),
|
||||
hmacSignatureSecret,
|
||||
hmacKeySignatureSecret,
|
||||
});
|
||||
}
|
||||
|
||||
async verify({token}: VerifyCaptchaParams): Promise<boolean> {
|
||||
const payload = decodePayload(token);
|
||||
if (!payload) return false;
|
||||
try {
|
||||
const result = await verifySolution({
|
||||
challenge: payload.challenge,
|
||||
solution: payload.solution,
|
||||
deriveKey,
|
||||
hmacSignatureSecret: this.options.hmacSignatureSecret,
|
||||
hmacKeySignatureSecret: this.options.hmacKeySignatureSecret,
|
||||
});
|
||||
if (!result.verified) {
|
||||
this.options.logger?.warn(
|
||||
{expired: result.expired, invalidSignature: result.invalidSignature, invalidSolution: result.invalidSolution},
|
||||
'ALTCHA verification failed',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger?.error({error}, 'Error verifying ALTCHA payload');
|
||||
return false;
|
||||
}
|
||||
const ttlSeconds = Math.max(1, payload.challenge.parameters.expiresAt - Math.floor(this.now() / 1000));
|
||||
return await this.options.claimChallenge(payload.challenge.signature, ttlSeconds);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
|
||||
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
@@ -82,6 +83,14 @@ function resolveTrustClientIpHeader(proxyConfig: object): boolean {
|
||||
function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
|
||||
const normalized = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (value.includes('/')) {
|
||||
const range = parseIpBanEntry(value);
|
||||
if (range?.type !== 'range') {
|
||||
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid CIDR range: ${value}`);
|
||||
}
|
||||
normalized.add(range.canonical);
|
||||
continue;
|
||||
}
|
||||
const parsed = parseIpAddress(value);
|
||||
if (!parsed) {
|
||||
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid IP address: ${value}`);
|
||||
|
||||
@@ -34,9 +34,14 @@ import {
|
||||
PendingRegistrationActionRequest,
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {PushServiceDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import {
|
||||
type PushServiceDeliveryConfig,
|
||||
PushServiceDeliveryConfigSchema,
|
||||
type PushServiceDeliveryConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
@@ -67,6 +72,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
voiceNoiseSuppression,
|
||||
pushServiceDelivery,
|
||||
domainMigration,
|
||||
altchaCaptcha,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -77,6 +83,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getPushServiceDeliveryConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
@@ -110,6 +117,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
push_service_delivery: pushServiceDelivery,
|
||||
domain_migration: domainMigration,
|
||||
altcha_captcha: altchaCaptcha,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -194,6 +202,20 @@ async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boole
|
||||
return true;
|
||||
}
|
||||
|
||||
function relayConsentStamp(
|
||||
current: PushServiceDeliveryConfig,
|
||||
patch: Partial<PushServiceDeliveryConfigUpdateRequest>,
|
||||
adminUserId: string,
|
||||
): Partial<PushServiceDeliveryConfig> {
|
||||
const accepted = patch.relay_consent_accepted;
|
||||
if (accepted === undefined || accepted === current.relay_consent_accepted) {
|
||||
return {};
|
||||
}
|
||||
return accepted
|
||||
? {relay_consent_accepted_at: new Date().toISOString(), relay_consent_accepted_by: adminUserId}
|
||||
: {relay_consent_accepted_at: null, relay_consent_accepted_by: null};
|
||||
}
|
||||
|
||||
function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
|
||||
const sections = Object.entries(data)
|
||||
.filter(([, value]) => value != null)
|
||||
@@ -276,10 +298,12 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
if (data.push_service_delivery) {
|
||||
const patch = omitUndefinedFields(data.push_service_delivery);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const adminUserId = ctx.get('adminUserId').toString();
|
||||
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
|
||||
PushServiceDeliveryConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
...relayConsentStamp(current, patch, adminUserId),
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
@@ -298,6 +322,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.altcha_captcha) {
|
||||
const patch = omitUndefinedFields(data.altcha_captcha);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateAltchaCaptchaConfig((current) =>
|
||||
AltchaCaptchaConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const patch = data.experiment_delivery;
|
||||
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
|
||||
|
||||
@@ -8,12 +8,14 @@ import * as AuthEmail from '@app/api/auth/AuthEmail';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import {createPasswordResetToken, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {IRiskHistoryRepository} from '@app/api/risk/HistoricalOutcomeRepository';
|
||||
import type {HistoricalOutcomeCode} from '@app/api/risk/RiskHistoryTypes';
|
||||
import {mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
|
||||
import {resolveAssignedTraits} from '@app/api/user/UserTraits';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '@app/api/utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '@app/api/utils/SessionClientIdentity';
|
||||
@@ -545,7 +547,7 @@ export class AdminUserSecurityService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const credentials = await userRepository.listWebAuthnCredentials(userId);
|
||||
const credentials = visibleWebAuthnCredentials(await userRepository.listWebAuthnCredentials(userId));
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
@@ -554,12 +556,9 @@ export class AdminUserSecurityService {
|
||||
auditLogReason,
|
||||
metadata: new Map([['credential_count', credentials.length.toString()]]),
|
||||
});
|
||||
return credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
}));
|
||||
return credentials.map((cred) =>
|
||||
mapWebAuthnCredentialToResponse(cred, this.deps.apiContext.services.config.auth.passkeys.rpId),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('push relay supplemental notice consent', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
const createAdmin = async (): Promise<TestAccount> =>
|
||||
await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
|
||||
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
|
||||
|
||||
const readConfig = (admin: TestAccount) =>
|
||||
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config');
|
||||
|
||||
it('reads back as unaccepted before an operator agrees', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const config = await readConfig(admin).execute();
|
||||
|
||||
expect(config.push_service_delivery).toMatchObject({
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('stamps the acting admin and the acceptance time when consent is given', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const updated = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(updated.push_service_delivery.relay_consent_accepted).toBe(true);
|
||||
expect(updated.push_service_delivery.relay_consent_accepted_by).toBe(admin.userId);
|
||||
expect(Date.parse(updated.push_service_delivery.relay_consent_accepted_at ?? '')).not.toBeNaN();
|
||||
});
|
||||
|
||||
it('keeps the first acceptance stamp when a later patch changes only the rollout', async () => {
|
||||
const admin = await createAdmin();
|
||||
const accepted = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const rolledOut = await patchConfig(admin, {
|
||||
push_service_delivery: {enabled: true, rollout_basis_points: 2500},
|
||||
}).execute();
|
||||
|
||||
expect(rolledOut.push_service_delivery).toMatchObject({
|
||||
enabled: true,
|
||||
rollout_basis_points: 2500,
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: accepted.push_service_delivery.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: admin.userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the stamp untouched when consent is re-sent unchanged', async () => {
|
||||
const admin = await createAdmin();
|
||||
const accepted = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const resent = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(resent.push_service_delivery.relay_consent_accepted_at).toBe(
|
||||
accepted.push_service_delivery.relay_consent_accepted_at,
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the stamp when an operator withdraws consent', async () => {
|
||||
const admin = await createAdmin();
|
||||
await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const withdrawn = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: false}}).execute();
|
||||
|
||||
expect(withdrawn.push_service_delivery).toMatchObject({
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an acceptance stamp supplied by the caller', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const updated = await patchConfig(admin, {
|
||||
push_service_delivery: {
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: '2020-01-01T00:00:00.000Z',
|
||||
relay_consent_accepted_by: '1500000000000000009',
|
||||
},
|
||||
}).execute();
|
||||
|
||||
expect(updated.push_service_delivery.relay_consent_accepted_at).not.toBe('2020-01-01T00:00:00.000Z');
|
||||
expect(updated.push_service_delivery.relay_consent_accepted_by).toBe(admin.userId);
|
||||
});
|
||||
|
||||
it('publishes the consent to the delivery services', async () => {
|
||||
const admin = await createAdmin();
|
||||
const publish = vi.mocked(PushServiceDeliveryConfigPublisher.prototype.publish);
|
||||
|
||||
await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(publish).toHaveBeenCalledWith(expect.objectContaining({relay_consent_accepted: true}));
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {registerAdminControllers} from '@app/api/admin/controllers/index';
|
||||
import {AttachmentController} from '@app/api/attachment/AttachmentController';
|
||||
import {AuthController} from '@app/api/auth/AuthController';
|
||||
import {OriginHandoffController} from '@app/api/auth/OriginHandoffController';
|
||||
import {PasskeyBridgeController} from '@app/api/auth/PasskeyBridgeController';
|
||||
import {BlueskyOAuthController} from '@app/api/bluesky/BlueskyOAuthController';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ChannelController} from '@app/api/channel/ChannelController';
|
||||
@@ -48,6 +49,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
registerAdminControllers(routes);
|
||||
AuthController(routes);
|
||||
OriginHandoffController(routes);
|
||||
PasskeyBridgeController(routes);
|
||||
AttachmentController(routes);
|
||||
ChannelController(routes);
|
||||
ConnectionController(routes);
|
||||
|
||||
@@ -447,7 +447,7 @@ export function AuthController(app: HonoApp) {
|
||||
'Retrieve WebAuthn authentication challenge and options for passwordless login with biometrics or security keys.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnAuthenticationOptions());
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnAuthenticationOptions(ctx.req.header('origin')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -490,7 +490,9 @@ export function AuthController(app: HonoApp) {
|
||||
'Retrieve WebAuthn challenge and options for multi-factor authentication. Requires the MFA ticket from initial login.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnMfaOptions(ctx.req.valid('json')));
|
||||
return ctx.json(
|
||||
await ctx.get('authRequestService').getWebAuthnMfaOptions(ctx.req.valid('json'), ctx.req.header('origin')),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {InviteService} from '@app/api/invite/InviteService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {AuthSession as AuthSessionModel} from '@app/api/models/AuthSession';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
|
||||
@@ -353,7 +354,7 @@ export async function login(
|
||||
const MFA_TICKET_MAX_ATTEMPTS = 5;
|
||||
const MFA_USER_MAX_ATTEMPTS = 10;
|
||||
|
||||
async function consumeMfaAttempt(
|
||||
export async function consumeMfaAttempt(
|
||||
ctx: ApiContext,
|
||||
{userId, ticket, field}: {userId: string; ticket: string; field: string},
|
||||
): Promise<void> {
|
||||
@@ -381,7 +382,7 @@ export async function loginMfaTotp(
|
||||
ctx: ApiContext,
|
||||
{code, ticket, request}: LoginMfaTotpParams,
|
||||
): Promise<LoginTokenResult> {
|
||||
const {users, cache, rateLimit} = ctx.services;
|
||||
const {users, cache} = ctx.services;
|
||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
@@ -405,21 +406,36 @@ export async function loginMfaTotp(
|
||||
if (!isValid) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
const [token] = await completeMfaLogin(ctx, user, ticket, request);
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
export async function createLoginSession(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
request: Request,
|
||||
): Promise<[token: string, AuthSessionModel]> {
|
||||
return AuthSession.createAuthSession(ctx, {user, origin: AuthSession.resolveSessionOrigin(ctx, request)});
|
||||
}
|
||||
|
||||
export async function completeMfaLogin(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
ticket: string,
|
||||
request: Request,
|
||||
): Promise<[token: string, AuthSessionModel]> {
|
||||
const {cache, rateLimit} = ctx.services;
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: user.id.toString(), token};
|
||||
return createLoginSession(ctx, user, request);
|
||||
}
|
||||
|
||||
export async function loginMfaWebAuthn(
|
||||
ctx: ApiContext,
|
||||
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
|
||||
): Promise<LoginTokenResult> {
|
||||
const {users, cache, rateLimit} = ctx.services;
|
||||
const {users, cache} = ctx.services;
|
||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
@@ -434,13 +450,7 @@ export async function loginMfaWebAuthn(
|
||||
}
|
||||
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
const [token] = await completeMfaLogin(ctx, user, ticket, request);
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
|
||||
+269
-155
@@ -3,13 +3,20 @@
|
||||
import {timingSafeEqual} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {
|
||||
type CredentialRpSelection,
|
||||
effectiveRpId,
|
||||
originRpId,
|
||||
selectCredentialRp,
|
||||
visibleWebAuthnCredentials,
|
||||
} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import {deriveSudoMethods, userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {MfaBackupCode} from '@app/api/models/MfaBackupCode';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {mapUserToPrivateResponse, mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
|
||||
import {TotpGenerator} from '@app/api/utils/TotpGenerator';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -22,7 +29,12 @@ import {PasskeyAuthenticationFailedError} from '@fluxer/errors/src/domains/auth/
|
||||
import {UnknownWebAuthnCredentialError} from '@fluxer/errors/src/domains/auth/UnknownWebAuthnCredentialError';
|
||||
import {WebAuthnCredentialLimitReachedError} from '@fluxer/errors/src/domains/auth/WebAuthnCredentialLimitReachedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {AuthenticationResponseJSON, RegistrationResponseJSON} from '@simplewebauthn/server';
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/server';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
@@ -33,7 +45,41 @@ import {
|
||||
} from '@simplewebauthn/server';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
|
||||
type WebAuthnChallengeContext = 'registration' | 'discoverable' | 'mfa' | 'sudo';
|
||||
type WebAuthnChallengeContext = 'registration' | 'discoverable' | 'mfa' | 'sudo' | 'bridge' | 'migration_registration';
|
||||
|
||||
interface WebAuthnChallengeEntry {
|
||||
context: WebAuthnChallengeContext;
|
||||
userId?: string;
|
||||
ticket?: string;
|
||||
rpId?: string;
|
||||
credentialIds?: Array<string> | null;
|
||||
}
|
||||
|
||||
interface WebAuthnChallengeScope {
|
||||
rpId: string;
|
||||
credentialIds: Array<string> | null;
|
||||
}
|
||||
|
||||
interface WebAuthnAuthenticationOptionsParams {
|
||||
selection: CredentialRpSelection | {rpId: string; credentials: null};
|
||||
context: WebAuthnChallengeContext;
|
||||
userId?: UserID;
|
||||
ticket?: string;
|
||||
}
|
||||
|
||||
interface WebAuthnRegistrationOptionsParams {
|
||||
rpId: string;
|
||||
context: WebAuthnChallengeContext;
|
||||
excludeCredentials: Array<WebAuthnCredential>;
|
||||
}
|
||||
|
||||
interface VerifiedWebAuthnRegistration {
|
||||
credentialId: string;
|
||||
publicKey: Buffer;
|
||||
counter: bigint;
|
||||
transports: Set<string> | null;
|
||||
rpId: string;
|
||||
}
|
||||
|
||||
interface SudoMfaVerificationParams {
|
||||
userId: UserID;
|
||||
@@ -122,37 +168,117 @@ export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnRegistrationOptions(ctx: ApiContext, userId: UserID) {
|
||||
function toCredentialDescriptor(credential: WebAuthnCredential) {
|
||||
return {
|
||||
id: credential.credentialId,
|
||||
transports: credential.transports
|
||||
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function storedRpId(ctx: ApiContext, rpId: string): string | null {
|
||||
return rpId === ctx.services.config.auth.passkeys.rpId ? null : rpId;
|
||||
}
|
||||
|
||||
export async function createWebAuthnRegistrationOptions(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
{rpId, context, excludeCredentials}: WebAuthnRegistrationOptionsParams,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const {users, config} = ctx.services;
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
const existingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
if (existingCredentials.length >= 10) {
|
||||
throw new WebAuthnCredentialLimitReachedError();
|
||||
}
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: config.auth.passkeys.rpName,
|
||||
rpID: config.auth.passkeys.rpId,
|
||||
rpID: rpId,
|
||||
userID: new TextEncoder().encode(user.id.toString()),
|
||||
userName: user.username!,
|
||||
userDisplayName: user.username!,
|
||||
attestationType: 'none',
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
excludeCredentials: existingCredentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
excludeCredentials: excludeCredentials.map(toCredentialDescriptor),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'registration', userId});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context, userId, rpId, credentialIds: null});
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnRegistrationOptions(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const existingCredentials = await ctx.services.users.listWebAuthnCredentials(userId);
|
||||
if (visibleWebAuthnCredentials(existingCredentials).length >= 10) {
|
||||
throw new WebAuthnCredentialLimitReachedError();
|
||||
}
|
||||
return createWebAuthnRegistrationOptions(ctx, userId, {
|
||||
rpId: originRpId(ctx, origin),
|
||||
context: 'registration',
|
||||
excludeCredentials: existingCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnRegistrationResponse(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
response: RegistrationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
context: WebAuthnChallengeContext,
|
||||
expectedOrigin: Array<string> = ctx.services.config.auth.passkeys.allowedOrigins,
|
||||
): Promise<VerifiedWebAuthnRegistration> {
|
||||
const {config} = ctx.services;
|
||||
const {rpId} = await consumeWebAuthnChallenge(ctx, expectedChallenge, context, {userId});
|
||||
const responseObj = response as {id?: string; response?: {transports?: Array<string>}};
|
||||
const transports = responseObj.response?.transports ? new Set(responseObj.response.transports) : null;
|
||||
if (config.dev.testModeEnabled) {
|
||||
const credentialId = responseObj.id ?? `test-credential:${userId.toString()}:${Date.now()}`;
|
||||
return {credentialId, publicKey: Buffer.from(`test-public-key:${credentialId}`), counter: 0n, transports, rpId};
|
||||
}
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpId,
|
||||
requireUserVerification: false,
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, expectedChallenge, rpId, expectedOrigin}, 'WebAuthn verification failed');
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
Logger.error(
|
||||
{userId, verified: verification.verified, hasRegistrationInfo: !!verification.registrationInfo},
|
||||
'WebAuthn verification result invalid',
|
||||
);
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
const {credential} = verification.registrationInfo;
|
||||
let publicKeyBuffer: Buffer;
|
||||
let counterBigInt: bigint;
|
||||
try {
|
||||
publicKeyBuffer = Buffer.from(credential.publicKey);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnPublicKeyFormatError();
|
||||
}
|
||||
try {
|
||||
if (credential.counter === undefined || credential.counter === null) {
|
||||
throw new Error('Counter value is undefined or null');
|
||||
}
|
||||
counterBigInt = BigInt(credential.counter);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnCredentialCounterError();
|
||||
}
|
||||
return {credentialId: credential.id, publicKey: publicKeyBuffer, counter: counterBigInt, transports, rpId};
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnRegistration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
@@ -160,85 +286,39 @@ export async function verifyWebAuthnRegistration(
|
||||
expectedChallenge: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const {users, config} = ctx.services;
|
||||
const {users} = ctx.services;
|
||||
const existingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
await consumeWebAuthnChallenge(ctx, expectedChallenge, 'registration', {userId});
|
||||
if (existingCredentials.length >= 10) {
|
||||
if (visibleWebAuthnCredentials(existingCredentials).length >= 10) {
|
||||
throw new WebAuthnCredentialLimitReachedError();
|
||||
}
|
||||
if (config.dev.testModeEnabled) {
|
||||
const responseObj = response as {id?: string; response?: {transports?: Array<string>}};
|
||||
const credentialId = responseObj.id ?? `test-credential:${userId.toString()}:${Date.now()}`;
|
||||
const publicKeyBuffer = Buffer.from(`test-public-key:${credentialId}`);
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
credentialId,
|
||||
publicKeyBuffer,
|
||||
0n,
|
||||
responseObj.response?.transports ? new Set(responseObj.response.transports) : null,
|
||||
name,
|
||||
);
|
||||
} else {
|
||||
const expectedOrigin = config.auth.passkeys.allowedOrigins;
|
||||
const rpID = config.auth.passkeys.rpId;
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, expectedChallenge, rpID, expectedOrigin}, 'WebAuthn verification failed');
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
Logger.error(
|
||||
{userId, verified: verification.verified, hasRegistrationInfo: !!verification.registrationInfo},
|
||||
'WebAuthn verification result invalid',
|
||||
);
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
const {credential} = verification.registrationInfo;
|
||||
let publicKeyBuffer: Buffer;
|
||||
let counterBigInt: bigint;
|
||||
try {
|
||||
publicKeyBuffer = Buffer.from(credential.publicKey);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnPublicKeyFormatError();
|
||||
}
|
||||
try {
|
||||
if (credential.counter === undefined || credential.counter === null) {
|
||||
throw new Error('Counter value is undefined or null');
|
||||
}
|
||||
counterBigInt = BigInt(credential.counter);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnCredentialCounterError();
|
||||
}
|
||||
const responseObj = response as {response?: {transports?: Array<string>}};
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
credential.id,
|
||||
publicKeyBuffer,
|
||||
counterBigInt,
|
||||
responseObj.response?.transports ? new Set(responseObj.response.transports) : null,
|
||||
name,
|
||||
);
|
||||
}
|
||||
const verified = await verifyWebAuthnRegistrationResponse(ctx, userId, response, expectedChallenge, 'registration');
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
verified.credentialId,
|
||||
verified.publicKey,
|
||||
verified.counter,
|
||||
verified.transports,
|
||||
name,
|
||||
storedRpId(ctx, verified.rpId),
|
||||
);
|
||||
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
|
||||
export async function deleteWebAuthnCredential(ctx: ApiContext, userId: UserID, credentialId: string): Promise<void> {
|
||||
const {users, gateway, botMfaMirror} = ctx.services;
|
||||
const credential = await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
if (!credential || credential.supersededBy !== null) {
|
||||
throw new UnknownWebAuthnCredentialError();
|
||||
}
|
||||
await users.deleteWebAuthnCredential(userId, credentialId);
|
||||
const remainingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
const remaining = await users.listWebAuthnCredentials(userId);
|
||||
const remainingCredentials = visibleWebAuthnCredentials(remaining);
|
||||
const orphanedTwins = remaining.filter(
|
||||
(cred) => cred.supersededBy === credentialId || (cred.supersededBy !== null && remainingCredentials.length === 0),
|
||||
);
|
||||
for (const twin of orphanedTwins) {
|
||||
await users.deleteWebAuthnCredential(userId, twin.credentialId);
|
||||
}
|
||||
if (remainingCredentials.length === 0) {
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
if (user.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
@@ -298,37 +378,66 @@ export async function renameWebAuthnCredential(
|
||||
): Promise<void> {
|
||||
const {users} = ctx.services;
|
||||
const credential = await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
if (!credential || credential.supersededBy !== null) {
|
||||
throw new UnknownWebAuthnCredentialError();
|
||||
}
|
||||
await users.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
|
||||
async function dispatchWebAuthnCredentialsUpdate(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {users, gateway} = ctx.services;
|
||||
export async function dispatchWebAuthnCredentialsUpdate(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {users, gateway, config} = ctx.services;
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
await gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'WEBAUTHN_CREDENTIALS_UPDATE',
|
||||
data: credentials.map((cred: WebAuthnCredential) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
})),
|
||||
data: visibleWebAuthnCredentials(credentials).map((cred) =>
|
||||
mapWebAuthnCredentialToResponse(cred, config.auth.passkeys.rpId),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateWebAuthnAuthenticationOptionsDiscoverable(ctx: ApiContext) {
|
||||
export async function generateWebAuthnAuthenticationOptions(
|
||||
ctx: ApiContext,
|
||||
{selection, context, userId, ticket}: WebAuthnAuthenticationOptionsParams,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: ctx.services.config.auth.passkeys.rpId,
|
||||
userVerification: 'required',
|
||||
rpID: selection.rpId,
|
||||
allowCredentials: selection.credentials?.map(toCredentialDescriptor),
|
||||
userVerification: selection.credentials === null ? 'required' : 'discouraged',
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {
|
||||
context,
|
||||
userId,
|
||||
ticket,
|
||||
rpId: selection.rpId,
|
||||
credentialIds: selection.credentials?.map((cred) => cred.credentialId) ?? null,
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'discoverable'});
|
||||
return options;
|
||||
}
|
||||
|
||||
function selectCredentialRpOrThrow(
|
||||
ctx: ApiContext,
|
||||
origin: string | null | undefined,
|
||||
credentials: Array<WebAuthnCredential>,
|
||||
): CredentialRpSelection {
|
||||
const selection = selectCredentialRp(ctx, origin, credentials);
|
||||
if (selection.credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnAuthenticationOptionsDiscoverable(
|
||||
ctx: ApiContext,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
return generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: {rpId: originRpId(ctx, origin), credentials: null},
|
||||
context: 'discoverable',
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnAuthenticationDiscoverable(
|
||||
ctx: ApiContext,
|
||||
response: AuthenticationResponseJSON,
|
||||
@@ -344,29 +453,24 @@ export async function verifyWebAuthnAuthenticationDiscoverable(
|
||||
return users.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
export async function generateWebAuthnAuthenticationOptionsForMfa(ctx: ApiContext, ticket: string) {
|
||||
const {users, cache, config} = ctx.services;
|
||||
export async function generateWebAuthnAuthenticationOptionsForMfa(
|
||||
ctx: ApiContext,
|
||||
ticket: string,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const {users, cache} = ctx.services;
|
||||
const userIdStr = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userIdStr) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
}
|
||||
const userId = createUserID(BigInt(userIdStr));
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
if (credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.auth.passkeys.rpId,
|
||||
allowCredentials: credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
userVerification: 'discouraged',
|
||||
return generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: selectCredentialRpOrThrow(ctx, origin, credentials),
|
||||
context: 'mfa',
|
||||
userId,
|
||||
ticket,
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'mfa', userId, ticket});
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnAuthentication(
|
||||
@@ -376,21 +480,26 @@ export async function verifyWebAuthnAuthentication(
|
||||
expectedChallenge: string,
|
||||
context: WebAuthnChallengeContext = 'mfa',
|
||||
ticket?: string,
|
||||
): Promise<void> {
|
||||
expectedOrigin: Array<string> = ctx.services.config.auth.passkeys.allowedOrigins,
|
||||
): Promise<WebAuthnCredential> {
|
||||
const {users, config} = ctx.services;
|
||||
await consumeWebAuthnChallenge(ctx, expectedChallenge, context, {userId, ticket});
|
||||
const scope = await consumeWebAuthnChallenge(ctx, expectedChallenge, context, {userId, ticket});
|
||||
const credentialId = (response as {id: string}).id;
|
||||
const credential = await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
throw new PasskeyAuthenticationFailedError();
|
||||
}
|
||||
if (
|
||||
effectiveRpId(ctx, credential) !== scope.rpId ||
|
||||
(scope.credentialIds !== null && !scope.credentialIds.includes(credentialId))
|
||||
) {
|
||||
throw new PasskeyAuthenticationFailedError();
|
||||
}
|
||||
if (config.dev.testModeEnabled) {
|
||||
await users.updateWebAuthnCredentialCounter(userId, credentialId, credential.counter + 1n);
|
||||
await users.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
return;
|
||||
return credential;
|
||||
}
|
||||
const expectedOrigin = config.auth.passkeys.allowedOrigins;
|
||||
const rpID = config.auth.passkeys.rpId;
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
let publicKeyUint8Array: Uint8Array<ArrayBuffer>;
|
||||
@@ -405,15 +514,12 @@ export async function verifyWebAuthnAuthentication(
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: requiresWebAuthnUserVerification(context),
|
||||
expectedRPID: scope.rpId,
|
||||
requireUserVerification: requiresWebAuthnUserVerification(context, scope),
|
||||
credential: {
|
||||
id: credential.credentialId,
|
||||
...toCredentialDescriptor(credential),
|
||||
publicKey: publicKeyUint8Array,
|
||||
counter: Number(credential.counter),
|
||||
transports: credential.transports
|
||||
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (_error) {
|
||||
@@ -434,31 +540,25 @@ export async function verifyWebAuthnAuthentication(
|
||||
}
|
||||
await users.updateWebAuthnCredentialCounter(userId, credentialId, newCounter);
|
||||
await users.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
return credential;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnOptionsForSudo(ctx: ApiContext, userId: UserID) {
|
||||
const {users, config} = ctx.services;
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
if (credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.auth.passkeys.rpId,
|
||||
allowCredentials: credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
userVerification: 'discouraged',
|
||||
export async function generateWebAuthnOptionsForSudo(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const credentials = await ctx.services.users.listWebAuthnCredentials(userId);
|
||||
return generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: selectCredentialRpOrThrow(ctx, origin, credentials),
|
||||
context: 'sudo',
|
||||
userId,
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'sudo', userId});
|
||||
return options;
|
||||
}
|
||||
|
||||
const SUDO_MFA_USER_MAX_ATTEMPTS = 10;
|
||||
|
||||
async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
export async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {rateLimit} = ctx.services;
|
||||
const userLimit = await rateLimit.checkLimit({
|
||||
identifier: `sudo-mfa:user:${userId}`,
|
||||
@@ -535,20 +635,33 @@ function webAuthnChallengeCacheKey(challenge: string): string {
|
||||
return `webauthn:challenge:${challenge}`;
|
||||
}
|
||||
|
||||
function requiresWebAuthnUserVerification(context: WebAuthnChallengeContext): boolean {
|
||||
return context === 'discoverable';
|
||||
function requiresWebAuthnUserVerification(context: WebAuthnChallengeContext, scope: WebAuthnChallengeScope): boolean {
|
||||
return context === 'discoverable' || (context === 'bridge' && scope.credentialIds === null);
|
||||
}
|
||||
|
||||
async function saveWebAuthnChallenge(
|
||||
ctx: ApiContext,
|
||||
challenge: string,
|
||||
entry: {context: WebAuthnChallengeContext; userId?: UserID; ticket?: string},
|
||||
entry: {
|
||||
context: WebAuthnChallengeContext;
|
||||
userId?: UserID;
|
||||
ticket?: string;
|
||||
rpId: string;
|
||||
credentialIds: Array<string> | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
await ctx.services.cache.set(
|
||||
webAuthnChallengeCacheKey(challenge),
|
||||
{context: entry.context, userId: entry.userId?.toString(), ticket: entry.ticket},
|
||||
seconds('5 minutes'),
|
||||
);
|
||||
const value: WebAuthnChallengeEntry = {
|
||||
context: entry.context,
|
||||
userId: entry.userId?.toString(),
|
||||
ticket: entry.ticket,
|
||||
rpId: entry.rpId,
|
||||
credentialIds: entry.credentialIds,
|
||||
};
|
||||
await ctx.services.cache.set(webAuthnChallengeCacheKey(challenge), value, seconds('5 minutes'));
|
||||
}
|
||||
|
||||
export async function deleteWebAuthnChallenge(ctx: ApiContext, challenge: string): Promise<void> {
|
||||
await ctx.services.cache.delete(webAuthnChallengeCacheKey(challenge));
|
||||
}
|
||||
|
||||
async function consumeWebAuthnChallenge(
|
||||
@@ -556,10 +669,8 @@ async function consumeWebAuthnChallenge(
|
||||
challenge: string,
|
||||
expectedContext: WebAuthnChallengeContext,
|
||||
{userId, ticket}: {userId?: UserID; ticket?: string} = {},
|
||||
): Promise<void> {
|
||||
const {cache} = ctx.services;
|
||||
const key = webAuthnChallengeCacheKey(challenge);
|
||||
const cached = await cache.get<{context: WebAuthnChallengeContext; userId?: string; ticket?: string}>(key);
|
||||
): Promise<WebAuthnChallengeScope> {
|
||||
const cached = await ctx.services.cache.getAndDelete<WebAuthnChallengeEntry>(webAuthnChallengeCacheKey(challenge));
|
||||
const challengeMatches =
|
||||
cached &&
|
||||
cached.context === expectedContext &&
|
||||
@@ -581,11 +692,14 @@ async function consumeWebAuthnChallenge(
|
||||
);
|
||||
throw createChallengeError(expectedContext);
|
||||
}
|
||||
await cache.delete(key);
|
||||
return {
|
||||
rpId: cached.rpId ?? ctx.services.config.auth.passkeys.rpId,
|
||||
credentialIds: cached.credentialIds ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function createChallengeError(context: WebAuthnChallengeContext) {
|
||||
if (context === 'registration') {
|
||||
if (context === 'registration' || context === 'migration_registration') {
|
||||
return new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
return new PasskeyAuthenticationFailedError();
|
||||
|
||||
@@ -280,21 +280,18 @@ export class AuthRequestService {
|
||||
return {completed: false};
|
||||
}
|
||||
|
||||
async getWebAuthnAuthenticationOptions() {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsDiscoverable(this.apiContext);
|
||||
async getWebAuthnAuthenticationOptions(origin: string | undefined) {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsDiscoverable(this.apiContext, origin);
|
||||
}
|
||||
|
||||
async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) {
|
||||
const user = await AuthMfa.verifyWebAuthnAuthenticationDiscoverable(this.apiContext, data.response, data.challenge);
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
|
||||
});
|
||||
const [token] = await AuthLogin.createLoginSession(this.apiContext, user, request);
|
||||
return {token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
|
||||
}
|
||||
|
||||
async getWebAuthnMfaOptions({ticket}: MfaTicketRequest) {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsForMfa(this.apiContext, ticket);
|
||||
async getWebAuthnMfaOptions({ticket}: MfaTicketRequest, origin: string | undefined) {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsForMfa(this.apiContext, ticket, origin);
|
||||
}
|
||||
|
||||
async loginMfaWebAuthn({data, request}: AuthWebAuthnMfaRequest): Promise<AuthTokenWithUserIdResponse> {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
cancelPasskeyBridge,
|
||||
completePasskeyBridge,
|
||||
getPasskeyBridgeOptions,
|
||||
redeemPasskeyBridgeLogin,
|
||||
redeemPasskeyBridgeSudo,
|
||||
startPasskeyBridgeLogin,
|
||||
startPasskeyBridgeSudo,
|
||||
} from '@app/api/auth/services/PasskeyBridgeService';
|
||||
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {LocalAuthMiddleware} from '@app/api/middleware/LocalAuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {
|
||||
PasskeyBridgeCeremonyIdParam,
|
||||
PasskeyBridgeCompleteRequest,
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeLoginRedeemResponse,
|
||||
PasskeyBridgeLoginStartRequest,
|
||||
PasskeyBridgeOptionsResponse,
|
||||
PasskeyBridgeRedeemRequest,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
PasskeyBridgeSudoStartRequest,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
|
||||
export function PasskeyBridgeController(app: HonoApp) {
|
||||
app.post(
|
||||
'/auth/passkey-bridge',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_START),
|
||||
Validator('json', PasskeyBridgeLoginStartRequest),
|
||||
OpenAPI({
|
||||
operationId: 'start_passkey_bridge_login',
|
||||
summary: 'Start passkey bridge sign in',
|
||||
responseSchema: PasskeyBridgeStartResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Start a sign in or two-factor ceremony for a passkey that belongs to the paired first-party origin. Only available on the official instance from the new origin.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await startPasskeyBridgeLogin(ctx.get('apiContext'), ctx.req.header('origin'), ctx.req.valid('json')),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/passkey-bridge',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PASSKEY_BRIDGE_START),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', PasskeyBridgeSudoStartRequest),
|
||||
OpenAPI({
|
||||
operationId: 'start_passkey_bridge_sudo',
|
||||
summary: 'Start passkey bridge sudo verification',
|
||||
responseSchema: PasskeyBridgeStartResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Start a sudo verification ceremony for a passkey that belongs to the paired first-party origin. Only available on the official instance from the new origin.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await startPasskeyBridgeSudo(
|
||||
ctx.get('apiContext'),
|
||||
ctx.req.header('origin'),
|
||||
ctx.get('user').id,
|
||||
ctx.req.valid('json'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/passkey-bridge/:ceremony_id/options',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_CEREMONY),
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_passkey_bridge_options',
|
||||
summary: 'Get passkey bridge options',
|
||||
responseSchema: PasskeyBridgeOptionsResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Issue WebAuthn authentication options for a pending passkey bridge ceremony. The request must come from the origin that runs the ceremony.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(await getPasskeyBridgeOptions(ctx.get('apiContext'), ceremony_id, ctx.req.header('origin')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/passkey-bridge/:ceremony_id/complete',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_CEREMONY),
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
Validator('json', PasskeyBridgeCompleteRequest),
|
||||
OpenAPI({
|
||||
operationId: 'complete_passkey_bridge',
|
||||
summary: 'Complete passkey bridge',
|
||||
responseSchema: PasskeyBridgeFinishResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Verify the WebAuthn response for a pending passkey bridge ceremony. A failed verification leaves the ceremony pending so it can be retried.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await completePasskeyBridge(
|
||||
ctx.get('apiContext'),
|
||||
ceremony_id,
|
||||
ctx.req.header('origin'),
|
||||
ctx.req.valid('json'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/passkey-bridge/:ceremony_id/cancel',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_CEREMONY),
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'cancel_passkey_bridge',
|
||||
summary: 'Cancel passkey bridge',
|
||||
responseSchema: PasskeyBridgeFinishResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description: 'Cancel a passkey bridge ceremony that has not completed.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(await cancelPasskeyBridge(ctx.get('apiContext'), ceremony_id, ctx.req.header('origin')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/passkey-bridge/:ceremony_id/redeem',
|
||||
LocalAuthMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_PASSKEY_BRIDGE_REDEEM),
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
Validator('json', PasskeyBridgeRedeemRequest),
|
||||
OpenAPI({
|
||||
operationId: 'redeem_passkey_bridge_login',
|
||||
summary: 'Redeem passkey bridge sign in',
|
||||
responseSchema: PasskeyBridgeLoginRedeemResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Redeem a finished sign in or two-factor passkey bridge ceremony once. Requires the nonce kept by the starting page and the completion code handed back when the ceremony finished.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await redeemPasskeyBridgeLogin(
|
||||
ctx.get('apiContext'),
|
||||
ceremony_id,
|
||||
ctx.req.header('origin'),
|
||||
ctx.req.valid('json'),
|
||||
ctx.req.raw,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/passkey-bridge/:ceremony_id/redeem',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PASSKEY_BRIDGE_REDEEM),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', PasskeyBridgeCeremonyIdParam),
|
||||
Validator('json', PasskeyBridgeRedeemRequest),
|
||||
OpenAPI({
|
||||
operationId: 'redeem_passkey_bridge_sudo',
|
||||
summary: 'Redeem passkey bridge sudo verification',
|
||||
responseSchema: PasskeyBridgeSudoRedeemResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Redeem a finished sudo passkey bridge ceremony once for a sudo mode token. Requires the nonce kept by the starting page and the completion code handed back when the ceremony finished.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {ceremony_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await redeemPasskeyBridgeSudo(
|
||||
ctx.get('apiContext'),
|
||||
ceremony_id,
|
||||
ctx.req.header('origin'),
|
||||
ctx.req.valid('json'),
|
||||
ctx.get('user').id,
|
||||
ctx.get('authSession'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthLogin from '@app/api/auth/AuthLogin';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {recordPendingPasskeyMigration} from '@app/api/auth/services/PasskeyMigrationService';
|
||||
import {
|
||||
effectiveRpId,
|
||||
isPasskeyMigrationActive,
|
||||
isPasskeyTargetOrigin,
|
||||
passkeyLegacyOriginFor,
|
||||
visibleWebAuthnCredentials,
|
||||
} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import {getSudoModeService} from '@app/api/auth/services/SudoModeService';
|
||||
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {AuthSession} from '@app/api/models/AuthSession';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {mapUserToPartialResponse} from '@app/api/user/UserMappers';
|
||||
import {PASSKEY_BRIDGE_PATH, PASSKEY_BRIDGE_RETURN_FRAGMENT_KEY} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InvalidPasskeyBridgeNonceError} from '@fluxer/errors/src/domains/auth/InvalidPasskeyBridgeNonceError';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import {NoPasskeysRegisteredError} from '@fluxer/errors/src/domains/auth/NoPasskeysRegisteredError';
|
||||
import {PasskeyAuthenticationFailedError} from '@fluxer/errors/src/domains/auth/PasskeyAuthenticationFailedError';
|
||||
import {UnknownPasskeyBridgeError} from '@fluxer/errors/src/domains/auth/UnknownPasskeyBridgeError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
|
||||
import type {
|
||||
PasskeyBridgeCompleteRequest,
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeLoginRedeemResponse,
|
||||
PasskeyBridgeLoginStartRequest,
|
||||
PasskeyBridgeRedeemRequest,
|
||||
PasskeyBridgeRunner,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
PasskeyBridgeSudoStartRequest,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
import type {PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/server';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
|
||||
type PasskeyBridgePurpose = 'login' | 'login_mfa' | 'sudo';
|
||||
|
||||
interface PasskeyBridgeRecord {
|
||||
purpose: PasskeyBridgePurpose;
|
||||
runner: PasskeyBridgeRunner;
|
||||
target_origin: string;
|
||||
ceremony_origin: string;
|
||||
nonce_hash: string;
|
||||
user_id: string | null;
|
||||
ticket: string | null;
|
||||
challenge: string | null;
|
||||
credential_id: string | null;
|
||||
cross_device: boolean;
|
||||
completion_code_hash: string | null;
|
||||
status: 'pending' | 'completed' | 'cancelled';
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
interface CompletedPasskeyBridge {
|
||||
record: PasskeyBridgeRecord;
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
const PASSKEY_BRIDGE_KEY_PREFIX = 'passkey_bridge:';
|
||||
const PASSKEY_BRIDGE_LOCK_PREFIX = 'passkey_bridge_lock:';
|
||||
const PASSKEY_BRIDGE_SECRET_BYTES = 32;
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function hashMatches(value: string, storedHash: string | null): boolean {
|
||||
if (storedHash === null) return false;
|
||||
const presented = Buffer.from(sha256Hex(value), 'hex');
|
||||
const stored = Buffer.from(storedHash, 'hex');
|
||||
return presented.length === stored.length && timingSafeEqual(presented, stored);
|
||||
}
|
||||
|
||||
function createSecret(): string {
|
||||
return randomBytes(PASSKEY_BRIDGE_SECRET_BYTES).toString('base64url');
|
||||
}
|
||||
|
||||
function passkeyBridgeKey(ceremonyId: string): string {
|
||||
return `${PASSKEY_BRIDGE_KEY_PREFIX}${sha256Hex(ceremonyId)}`;
|
||||
}
|
||||
|
||||
async function writeRecord(ctx: ApiContext, ceremonyId: string, record: PasskeyBridgeRecord): Promise<void> {
|
||||
const ttlSeconds = Math.floor((record.expires_at - Date.now()) / 1000);
|
||||
if (ttlSeconds <= 0) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
await ctx.services.cache.set(passkeyBridgeKey(ceremonyId), record, ttlSeconds);
|
||||
}
|
||||
|
||||
function assertCeremonyOrigin(
|
||||
ctx: ApiContext,
|
||||
record: PasskeyBridgeRecord,
|
||||
origin: string | undefined,
|
||||
expectedOrigin: string,
|
||||
): void {
|
||||
if (origin !== expectedOrigin || !isPasskeyTargetOrigin(ctx, record.target_origin)) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateRecord<T>(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
mutate: (record: PasskeyBridgeRecord) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const {cache} = ctx.services;
|
||||
const lockKey = `${PASSKEY_BRIDGE_LOCK_PREFIX}${sha256Hex(ceremonyId)}`;
|
||||
const lockToken = await cache.acquireLock(lockKey, seconds('10 seconds'));
|
||||
if (!lockToken) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
try {
|
||||
const record = await cache.get<PasskeyBridgeRecord>(passkeyBridgeKey(ceremonyId));
|
||||
if (!record) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
assertCeremonyOrigin(ctx, record, origin, record.ceremony_origin);
|
||||
return await mutate(record);
|
||||
} finally {
|
||||
await cache.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
async function requireMfaTicketUser(ctx: ApiContext, ticket: string, expectedUserId?: string): Promise<User> {
|
||||
const userId = await ctx.services.cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId || (expectedUserId !== undefined && userId !== expectedUserId)) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
}
|
||||
const user = await ctx.services.users.findUniqueAssert(createUserID(BigInt(userId)));
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function requireLegacyCredentials(ctx: ApiContext, userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
const legacyRpId = ctx.services.config.auth.passkeys.rpId;
|
||||
const credentials = visibleWebAuthnCredentials(await ctx.services.users.listWebAuthnCredentials(userId)).filter(
|
||||
(credential) => effectiveRpId(ctx, credential) === legacyRpId,
|
||||
);
|
||||
if (credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
function assertBridgeStartOrigin(ctx: ApiContext, origin: string | undefined): string {
|
||||
if (!origin || !isPasskeyTargetOrigin(ctx, origin)) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
async function startPasskeyBridge(
|
||||
ctx: ApiContext,
|
||||
origin: string,
|
||||
fields: Pick<PasskeyBridgeRecord, 'purpose' | 'runner' | 'nonce_hash' | 'user_id' | 'ticket'>,
|
||||
): Promise<PasskeyBridgeStartResponse> {
|
||||
const ceremonyId = createSecret();
|
||||
const createdAt = Date.now();
|
||||
const ceremonyOrigin = fields.runner === 'page' ? passkeyLegacyOriginFor(origin) : origin;
|
||||
await writeRecord(ctx, ceremonyId, {
|
||||
...fields,
|
||||
target_origin: origin,
|
||||
ceremony_origin: ceremonyOrigin,
|
||||
challenge: null,
|
||||
credential_id: null,
|
||||
cross_device: false,
|
||||
completion_code_hash: null,
|
||||
status: 'pending',
|
||||
created_at: createdAt,
|
||||
expires_at: createdAt + (fields.purpose === 'login_mfa' ? ms('5 minutes') : ms('10 minutes')),
|
||||
});
|
||||
return {
|
||||
ceremony_id: ceremonyId,
|
||||
bridge_url: fields.runner === 'page' ? `${ceremonyOrigin}${PASSKEY_BRIDGE_PATH}#${ceremonyId}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startPasskeyBridgeLogin(
|
||||
ctx: ApiContext,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeLoginStartRequest,
|
||||
): Promise<PasskeyBridgeStartResponse> {
|
||||
const targetOrigin = assertBridgeStartOrigin(ctx, origin);
|
||||
let userId: string | null = null;
|
||||
if (data.purpose === 'login_mfa') {
|
||||
const user = await requireMfaTicketUser(ctx, data.ticket!);
|
||||
if (!(await resolveWebAuthnSecondFactor(ctx, user))) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
await requireLegacyCredentials(ctx, user.id);
|
||||
userId = user.id.toString();
|
||||
}
|
||||
return startPasskeyBridge(ctx, targetOrigin, {
|
||||
purpose: data.purpose,
|
||||
runner: data.runner,
|
||||
nonce_hash: data.nonce_hash,
|
||||
user_id: userId,
|
||||
ticket: data.ticket ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function startPasskeyBridgeSudo(
|
||||
ctx: ApiContext,
|
||||
origin: string | undefined,
|
||||
userId: UserID,
|
||||
data: PasskeyBridgeSudoStartRequest,
|
||||
): Promise<PasskeyBridgeStartResponse> {
|
||||
const targetOrigin = assertBridgeStartOrigin(ctx, origin);
|
||||
await requireLegacyCredentials(ctx, userId);
|
||||
return startPasskeyBridge(ctx, targetOrigin, {
|
||||
purpose: 'sudo',
|
||||
runner: data.runner,
|
||||
nonce_hash: data.nonce_hash,
|
||||
user_id: userId.toString(),
|
||||
ticket: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPasskeyBridgeOptions(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
): Promise<{options: PublicKeyCredentialRequestOptionsJSON}> {
|
||||
return mutateRecord(ctx, ceremonyId, origin, async (record) => {
|
||||
if (record.status !== 'pending') {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
const legacyRpId = ctx.services.config.auth.passkeys.rpId;
|
||||
const userId = record.user_id === null ? undefined : createUserID(BigInt(record.user_id));
|
||||
const options = await AuthMfa.generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: {
|
||||
rpId: legacyRpId,
|
||||
credentials: userId === undefined ? null : await requireLegacyCredentials(ctx, userId),
|
||||
},
|
||||
context: 'bridge',
|
||||
userId,
|
||||
});
|
||||
if (record.challenge !== null) {
|
||||
await AuthMfa.deleteWebAuthnChallenge(ctx, record.challenge);
|
||||
}
|
||||
await writeRecord(ctx, ceremonyId, {...record, challenge: options.challenge});
|
||||
return {options};
|
||||
});
|
||||
}
|
||||
|
||||
function buildReturnUrl(record: PasskeyBridgeRecord, ceremonyId: string, completionCode: string): string {
|
||||
return `${record.target_origin}${PASSKEY_BRIDGE_PATH}#${PASSKEY_BRIDGE_RETURN_FRAGMENT_KEY}=${ceremonyId}.${completionCode}`;
|
||||
}
|
||||
|
||||
async function finishRecord(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
record: PasskeyBridgeRecord,
|
||||
): Promise<PasskeyBridgeFinishResponse> {
|
||||
const completionCode = createSecret();
|
||||
await writeRecord(ctx, ceremonyId, {...record, completion_code_hash: sha256Hex(completionCode)});
|
||||
if (record.runner === 'native') {
|
||||
return {return_url: null, completion_code: completionCode};
|
||||
}
|
||||
return {return_url: buildReturnUrl(record, ceremonyId, completionCode), completion_code: null};
|
||||
}
|
||||
|
||||
export async function completePasskeyBridge(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeCompleteRequest,
|
||||
): Promise<PasskeyBridgeFinishResponse> {
|
||||
return mutateRecord(ctx, ceremonyId, origin, async (record) => {
|
||||
if (record.status !== 'pending') {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
const {users} = ctx.services;
|
||||
const credentialId = data.response.id;
|
||||
const userId =
|
||||
record.user_id === null
|
||||
? await users.getUserIdByCredentialId(credentialId)
|
||||
: createUserID(BigInt(record.user_id));
|
||||
const credential = userId === null ? null : await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (
|
||||
userId === null ||
|
||||
record.challenge === null ||
|
||||
credential === null ||
|
||||
credential.supersededBy !== null ||
|
||||
effectiveRpId(ctx, credential) !== ctx.services.config.auth.passkeys.rpId
|
||||
) {
|
||||
throw new PasskeyAuthenticationFailedError();
|
||||
}
|
||||
if (record.purpose === 'login_mfa') {
|
||||
await requireMfaTicketUser(ctx, record.ticket!, record.user_id!);
|
||||
await AuthLogin.consumeMfaAttempt(ctx, {userId: record.user_id!, ticket: record.ticket!, field: 'ticket'});
|
||||
} else if (record.purpose === 'sudo') {
|
||||
await AuthMfa.consumeSudoMfaAttempt(ctx, userId);
|
||||
}
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, userId, data.response, record.challenge, 'bridge', undefined, [
|
||||
record.ceremony_origin,
|
||||
]);
|
||||
return finishRecord(ctx, ceremonyId, {
|
||||
...record,
|
||||
status: 'completed',
|
||||
user_id: userId.toString(),
|
||||
credential_id: credentialId,
|
||||
cross_device: data.response.authenticatorAttachment === 'cross-platform',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelPasskeyBridge(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
): Promise<PasskeyBridgeFinishResponse> {
|
||||
return mutateRecord(ctx, ceremonyId, origin, async (record) => {
|
||||
if (record.status === 'completed') {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
return finishRecord(ctx, ceremonyId, {...record, status: 'cancelled'});
|
||||
});
|
||||
}
|
||||
|
||||
function assertRedeemable(
|
||||
record: PasskeyBridgeRecord | null,
|
||||
purposes: ReadonlyArray<PasskeyBridgePurpose>,
|
||||
expectedUserId: UserID | null,
|
||||
): asserts record is PasskeyBridgeRecord {
|
||||
if (
|
||||
!record ||
|
||||
!purposes.includes(record.purpose) ||
|
||||
(expectedUserId !== null && record.user_id !== expectedUserId.toString()) ||
|
||||
record.status === 'pending'
|
||||
) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
}
|
||||
|
||||
async function redeemPasskeyBridge(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeRedeemRequest,
|
||||
purposes: ReadonlyArray<PasskeyBridgePurpose>,
|
||||
expectedUserId: UserID | null,
|
||||
): Promise<CompletedPasskeyBridge | null> {
|
||||
const {cache} = ctx.services;
|
||||
const key = passkeyBridgeKey(ceremonyId);
|
||||
const record = await cache.get<PasskeyBridgeRecord>(key);
|
||||
if (!record) {
|
||||
throw new UnknownPasskeyBridgeError();
|
||||
}
|
||||
assertCeremonyOrigin(ctx, record, origin, record.target_origin);
|
||||
assertRedeemable(record, purposes, expectedUserId);
|
||||
if (!hashMatches(data.nonce, record.nonce_hash) || !hashMatches(data.completion_code, record.completion_code_hash)) {
|
||||
await cache.delete(key);
|
||||
throw new InvalidPasskeyBridgeNonceError();
|
||||
}
|
||||
const taken = await cache.getAndDelete<PasskeyBridgeRecord>(key);
|
||||
assertRedeemable(taken, purposes, expectedUserId);
|
||||
if (!hashMatches(data.nonce, taken.nonce_hash) || !hashMatches(data.completion_code, taken.completion_code_hash)) {
|
||||
throw new InvalidPasskeyBridgeNonceError();
|
||||
}
|
||||
if (taken.status === 'cancelled') {
|
||||
return null;
|
||||
}
|
||||
return {record: taken, userId: createUserID(BigInt(taken.user_id!))};
|
||||
}
|
||||
|
||||
async function recordMigrationIfActive(
|
||||
ctx: ApiContext,
|
||||
origin: string | undefined,
|
||||
completed: CompletedPasskeyBridge,
|
||||
authSession: AuthSession | undefined,
|
||||
): Promise<void> {
|
||||
if (!authSession || !(await isPasskeyMigrationActive(ctx, origin))) return;
|
||||
await recordPendingPasskeyMigration(ctx, authSession, {
|
||||
user_id: completed.userId.toString(),
|
||||
credential_id: completed.record.credential_id!,
|
||||
cross_device: completed.record.cross_device,
|
||||
});
|
||||
}
|
||||
|
||||
export async function redeemPasskeyBridgeLogin(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeRedeemRequest,
|
||||
request: Request,
|
||||
): Promise<PasskeyBridgeLoginRedeemResponse> {
|
||||
const completed = await redeemPasskeyBridge(ctx, ceremonyId, origin, data, ['login', 'login_mfa'], null);
|
||||
if (!completed) {
|
||||
return {status: 'cancelled'};
|
||||
}
|
||||
let token: string;
|
||||
let authSession: AuthSession;
|
||||
let user: User;
|
||||
if (completed.record.purpose === 'login_mfa') {
|
||||
user = await requireMfaTicketUser(ctx, completed.record.ticket!, completed.record.user_id!);
|
||||
if (!(await resolveWebAuthnSecondFactor(ctx, user))) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
[token, authSession] = await AuthLogin.completeMfaLogin(ctx, user, completed.record.ticket!, request);
|
||||
} else {
|
||||
user = await ctx.services.users.findUniqueAssert(completed.userId);
|
||||
[token, authSession] = await AuthLogin.createLoginSession(ctx, user, request);
|
||||
}
|
||||
await recordMigrationIfActive(ctx, origin, completed, authSession);
|
||||
return {status: 'completed', token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
|
||||
}
|
||||
|
||||
export async function redeemPasskeyBridgeSudo(
|
||||
ctx: ApiContext,
|
||||
ceremonyId: string,
|
||||
origin: string | undefined,
|
||||
data: PasskeyBridgeRedeemRequest,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
): Promise<PasskeyBridgeSudoRedeemResponse> {
|
||||
const completed = await redeemPasskeyBridge(ctx, ceremonyId, origin, data, ['sudo'], userId);
|
||||
if (!completed) {
|
||||
return {status: 'cancelled'};
|
||||
}
|
||||
const sudoToken = await getSudoModeService().generateSudoToken(userId);
|
||||
await recordMigrationIfActive(ctx, origin, completed, authSession);
|
||||
return {status: 'completed', sudo_token: sudoToken};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import {
|
||||
effectiveRpId,
|
||||
isPasskeyTargetOrigin,
|
||||
visibleWebAuthnCredentials,
|
||||
} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import type {AuthSession} from '@app/api/models/AuthSession';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {UnknownPasskeyMigrationError} from '@fluxer/errors/src/domains/auth/UnknownPasskeyMigrationError';
|
||||
import type {
|
||||
PasskeyMigrationCompleteRequest,
|
||||
PasskeyMigrationResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
|
||||
import type {PublicKeyCredentialCreationOptionsJSON} from '@simplewebauthn/server';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
const PASSKEY_MIGRATION_KEY_PREFIX = 'passkey_migration:';
|
||||
|
||||
interface PendingPasskeyMigration {
|
||||
user_id: string;
|
||||
credential_id: string;
|
||||
cross_device: boolean;
|
||||
}
|
||||
|
||||
interface LivePasskeyMigration {
|
||||
key: string;
|
||||
pending: PendingPasskeyMigration;
|
||||
credential: WebAuthnCredential;
|
||||
}
|
||||
|
||||
function passkeyMigrationKey(authSession: AuthSession): string {
|
||||
return `${PASSKEY_MIGRATION_KEY_PREFIX}${authSession.sessionIdHash.toString('base64url')}`;
|
||||
}
|
||||
|
||||
function isLegacyVisibleCredential(ctx: ApiContext, credential: WebAuthnCredential): boolean {
|
||||
return credential.supersededBy === null && effectiveRpId(ctx, credential) === ctx.services.config.auth.passkeys.rpId;
|
||||
}
|
||||
|
||||
export async function recordPendingPasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
authSession: AuthSession,
|
||||
pending: PendingPasskeyMigration,
|
||||
): Promise<void> {
|
||||
await ctx.services.cache.set(passkeyMigrationKey(authSession), pending, seconds('5 minutes'));
|
||||
}
|
||||
|
||||
async function loadLivePasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
): Promise<LivePasskeyMigration | null> {
|
||||
if (!authSession) return null;
|
||||
const {cache, users} = ctx.services;
|
||||
const key = passkeyMigrationKey(authSession);
|
||||
const pending = await cache.get<PendingPasskeyMigration>(key);
|
||||
if (!pending) return null;
|
||||
const credential =
|
||||
pending.user_id === userId.toString() ? await users.getWebAuthnCredential(userId, pending.credential_id) : null;
|
||||
if (credential === null || !isLegacyVisibleCredential(ctx, credential)) {
|
||||
await cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return {key, pending, credential};
|
||||
}
|
||||
|
||||
async function requireLivePasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
origin: string | undefined,
|
||||
): Promise<LivePasskeyMigration> {
|
||||
const live = isPasskeyTargetOrigin(ctx, origin) ? await loadLivePasskeyMigration(ctx, userId, authSession) : null;
|
||||
if (!live) {
|
||||
throw new UnknownPasskeyMigrationError();
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
async function takeLivePasskeyMigration(ctx: ApiContext, userId: UserID, key: string): Promise<WebAuthnCredential> {
|
||||
const pending = await ctx.services.cache.getAndDelete<PendingPasskeyMigration>(key);
|
||||
if (!pending || pending.user_id !== userId.toString()) {
|
||||
throw new UnknownPasskeyMigrationError();
|
||||
}
|
||||
const credential = await ctx.services.users.getWebAuthnCredential(userId, pending.credential_id);
|
||||
if (credential === null || !isLegacyVisibleCredential(ctx, credential)) {
|
||||
throw new UnknownPasskeyMigrationError();
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
function visibleTargetCredentials(ctx: ApiContext, credentials: Array<WebAuthnCredential>): Array<WebAuthnCredential> {
|
||||
return visibleWebAuthnCredentials(credentials).filter(
|
||||
(credential) => effectiveRpId(ctx, credential) === PASSKEY_MIGRATION_RP_ID,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
): Promise<PasskeyMigrationResponse> {
|
||||
const live = await loadLivePasskeyMigration(ctx, userId, authSession);
|
||||
if (!live) return {pending: null};
|
||||
return {
|
||||
pending: {
|
||||
credential_id: live.credential.credentialId,
|
||||
name: live.credential.name,
|
||||
cross_device: live.pending.cross_device,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPasskeyMigrationRegistrationOptions(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
origin: string | undefined,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const live = await requireLivePasskeyMigration(ctx, userId, authSession, origin);
|
||||
const credentials = await ctx.services.users.listWebAuthnCredentials(userId);
|
||||
const options = await AuthMfa.createWebAuthnRegistrationOptions(ctx, userId, {
|
||||
rpId: PASSKEY_MIGRATION_RP_ID,
|
||||
context: 'migration_registration',
|
||||
excludeCredentials: visibleTargetCredentials(ctx, credentials),
|
||||
});
|
||||
if (live.pending.cross_device) {
|
||||
options.hints = ['hybrid', 'security-key'];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function completePasskeyMigration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
authSession: AuthSession | undefined,
|
||||
origin: string | undefined,
|
||||
data: PasskeyMigrationCompleteRequest,
|
||||
): Promise<void> {
|
||||
const {users} = ctx.services;
|
||||
const live = await requireLivePasskeyMigration(ctx, userId, authSession, origin);
|
||||
const verified = await AuthMfa.verifyWebAuthnRegistrationResponse(
|
||||
ctx,
|
||||
userId,
|
||||
data.response,
|
||||
data.challenge,
|
||||
'migration_registration',
|
||||
[origin!],
|
||||
);
|
||||
const legacy = await takeLivePasskeyMigration(ctx, userId, live.key);
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
verified.credentialId,
|
||||
verified.publicKey,
|
||||
verified.counter,
|
||||
verified.transports,
|
||||
legacy.name,
|
||||
AuthMfa.storedRpId(ctx, verified.rpId),
|
||||
);
|
||||
await users.setWebAuthnCredentialSupersededBy(userId, legacy.credentialId, verified.credentialId);
|
||||
await AuthMfa.dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
|
||||
const PASSKEY_TARGET_TO_LEGACY_ORIGIN: ReadonlyMap<string, string> = new Map([
|
||||
['https://fluxer.com', 'https://web.fluxer.app'],
|
||||
['https://canary.fluxer.com', 'https://web.canary.fluxer.app'],
|
||||
]);
|
||||
|
||||
export interface CredentialRpSelection {
|
||||
rpId: string;
|
||||
credentials: Array<WebAuthnCredential>;
|
||||
}
|
||||
|
||||
export function isPasskeyTargetOrigin(ctx: ApiContext, origin: string | null | undefined): boolean {
|
||||
if (ctx.services.config.instance.selfHosted || !origin) return false;
|
||||
return PASSKEY_TARGET_TO_LEGACY_ORIGIN.has(origin);
|
||||
}
|
||||
|
||||
export function passkeyLegacyOriginFor(targetOrigin: string): string {
|
||||
return PASSKEY_TARGET_TO_LEGACY_ORIGIN.get(targetOrigin)!;
|
||||
}
|
||||
|
||||
export function effectiveRpId(ctx: ApiContext, credential: WebAuthnCredential): string {
|
||||
return credential.rpId ?? ctx.services.config.auth.passkeys.rpId;
|
||||
}
|
||||
|
||||
export function visibleWebAuthnCredentials(credentials: Array<WebAuthnCredential>): Array<WebAuthnCredential> {
|
||||
return credentials.filter((credential) => credential.supersededBy === null);
|
||||
}
|
||||
|
||||
export function originRpId(ctx: ApiContext, origin: string | null | undefined): string {
|
||||
return isPasskeyTargetOrigin(ctx, origin) ? PASSKEY_MIGRATION_RP_ID : ctx.services.config.auth.passkeys.rpId;
|
||||
}
|
||||
|
||||
export async function isPasskeyMigrationActive(ctx: ApiContext, origin: string | null | undefined): Promise<boolean> {
|
||||
if (!isPasskeyTargetOrigin(ctx, origin)) return false;
|
||||
const config = await getInstanceConfigRepository().getDomainMigrationConfig();
|
||||
return config.enabled;
|
||||
}
|
||||
|
||||
function credentialGroup(ctx: ApiContext, credentials: Array<WebAuthnCredential>, rpId: string): CredentialRpSelection {
|
||||
return {rpId, credentials: credentials.filter((credential) => effectiveRpId(ctx, credential) === rpId)};
|
||||
}
|
||||
|
||||
export function selectCredentialRp(
|
||||
ctx: ApiContext,
|
||||
origin: string | null | undefined,
|
||||
credentials: Array<WebAuthnCredential>,
|
||||
): CredentialRpSelection {
|
||||
const legacyRpId = ctx.services.config.auth.passkeys.rpId;
|
||||
const visible = visibleWebAuthnCredentials(credentials);
|
||||
if (isPasskeyTargetOrigin(ctx, origin)) {
|
||||
const target = credentialGroup(ctx, visible, PASSKEY_MIGRATION_RP_ID);
|
||||
return target.credentials.length > 0 ? target : credentialGroup(ctx, visible, legacyRpId);
|
||||
}
|
||||
const legacy = credentialGroup(ctx, credentials, legacyRpId);
|
||||
return legacy.credentials.length > 0 ? legacy : credentialGroup(ctx, visible, PASSKEY_MIGRATION_RP_ID);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {
|
||||
type AltchaCaptchaConfig,
|
||||
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {solveChallenge} from 'altcha-lib';
|
||||
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface CaptchaErrorBody {
|
||||
code: string;
|
||||
captcha_provider?: string;
|
||||
altcha_challenge?: Challenge;
|
||||
}
|
||||
|
||||
const FORGOT_PATH = '/auth/forgot';
|
||||
const FORGOT_BODY = {email: '[email protected]'};
|
||||
|
||||
async function setAltchaConfig(overrides: Partial<AltchaCaptchaConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
cost: 1000,
|
||||
max_counter: 100,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function solve(challenge: Challenge): Promise<string> {
|
||||
const solution = await solveChallenge({challenge, deriveKey, timeout: 0});
|
||||
if (!solution) throw new Error('ALTCHA challenge was not solved');
|
||||
return Buffer.from(JSON.stringify({challenge, solution}), 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
async function rejectWith(builder: TestRequestBuilder<CaptchaErrorBody>, code: string): Promise<CaptchaErrorBody> {
|
||||
const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse();
|
||||
expect(json.code).toBe(code);
|
||||
return json;
|
||||
}
|
||||
|
||||
function forgot(harness: ApiTestHarness): TestRequestBuilder<CaptchaErrorBody> {
|
||||
return createBuilderWithoutAuth<CaptchaErrorBody>(harness).post(FORGOT_PATH).body(FORGOT_BODY);
|
||||
}
|
||||
|
||||
describe('ALTCHA captcha experiment', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let previousCaptchaEnabled: boolean;
|
||||
let previousTestModeEnabled: boolean;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
previousCaptchaEnabled = Config.captcha.enabled;
|
||||
previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.captcha.enabled = previousCaptchaEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('keeps the configured provider while the experiment is off', async () => {
|
||||
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(body).not.toHaveProperty('captcha_provider');
|
||||
expect(body).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('leaves anonymous requests on the configured provider unless anonymous_enabled is set', async () => {
|
||||
await setAltchaConfig({rollout_basis_points: 10000});
|
||||
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(body).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('serves anonymous requests a challenge and accepts the solved payload once', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(required.captcha_provider).toBe('altcha');
|
||||
expect(required.altcha_challenge?.parameters).toMatchObject({algorithm: 'PBKDF2/SHA-256', cost: 1000});
|
||||
const token = await solve(required.altcha_challenge as Challenge);
|
||||
|
||||
await forgot(harness)
|
||||
.header('X-Captcha-Token', token)
|
||||
.header('X-Captcha-Type', 'altcha')
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const replayed = await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
expect(replayed.captcha_provider).toBe('altcha');
|
||||
expect(replayed.altcha_challenge?.signature).not.toBe(required.altcha_challenge?.signature);
|
||||
});
|
||||
|
||||
it('rejects a payload whose derived key does not match the challenge', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const challenge = required.altcha_challenge as Challenge;
|
||||
const forged = Buffer.from(
|
||||
JSON.stringify({challenge, solution: {counter: 1, derivedKey: '00'.repeat(32)}}),
|
||||
'utf8',
|
||||
).toString('base64');
|
||||
|
||||
await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', forged).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an ALTCHA payload from a requester outside the experiment', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const token = await solve(required.altcha_challenge as Challenge);
|
||||
await setAltchaConfig({anonymous_enabled: false});
|
||||
|
||||
const rejected = await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
expect(rejected).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('buckets signed-in users by their own rollout and still accepts the configured provider', async () => {
|
||||
Config.captcha.enabled = false;
|
||||
const included = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
Config.captcha.enabled = true;
|
||||
await setAltchaConfig({
|
||||
anonymous_enabled: true,
|
||||
included_user_ids: [included.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
const redeemPath = '/gifts/altcha-gift-code/redeem';
|
||||
|
||||
const excludedBody = await rejectWith(
|
||||
createBuilder<CaptchaErrorBody>(harness, excluded.token).post(redeemPath),
|
||||
APIErrorCodes.CAPTCHA_REQUIRED,
|
||||
);
|
||||
expect(excludedBody).not.toHaveProperty('altcha_challenge');
|
||||
|
||||
const includedBody = await rejectWith(
|
||||
createBuilder<CaptchaErrorBody>(harness, included.token).post(redeemPath),
|
||||
APIErrorCodes.CAPTCHA_REQUIRED,
|
||||
);
|
||||
const token = await solve(includedBody.altcha_challenge as Challenge);
|
||||
const solved = await createBuilder<CaptchaErrorBody>(harness, included.token)
|
||||
.post(redeemPath)
|
||||
.header('X-Captcha-Token', token)
|
||||
.header('X-Captcha-Type', 'altcha')
|
||||
.executeRaw();
|
||||
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(solved.json?.code);
|
||||
|
||||
const classic = await createBuilder<CaptchaErrorBody>(harness, included.token)
|
||||
.post(redeemPath)
|
||||
.header('X-Captcha-Token', 'hcaptcha-token')
|
||||
.header('X-Captcha-Type', 'hcaptcha')
|
||||
.executeRaw();
|
||||
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(classic.json?.code);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
type LoginMfaResponse,
|
||||
loginUser,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
type BridgeNonce,
|
||||
createBridgeNonce,
|
||||
LEGACY_ORIGIN,
|
||||
LEGACY_RP_ID,
|
||||
registerPasskey,
|
||||
runNativeSudoBridge,
|
||||
setDomainMigration,
|
||||
TARGET_ORIGIN,
|
||||
} from '@app/api/auth/tests/PasskeyTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createTotpSecret,
|
||||
createWebAuthnDevice,
|
||||
generateTotpCode,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {getCacheService} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeLoginRedeemResponse,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
|
||||
|
||||
interface StartedBridge {
|
||||
ceremonyId: string;
|
||||
bridgeUrl: string | null;
|
||||
nonce: BridgeNonce;
|
||||
}
|
||||
|
||||
describe('Passkey bridge', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await setDomainMigration(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().instance.selfHosted = false;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function createLegacyAccount(): Promise<{account: TestAccount; device: WebAuthnDevice}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, device, {password: account.password}, 'Old');
|
||||
return {account, device};
|
||||
}
|
||||
|
||||
async function startLogin(body: Record<string, unknown> = {}, origin = TARGET_ORIGIN): Promise<StartedBridge> {
|
||||
const nonce = createBridgeNonce();
|
||||
const start = await createBuilderWithoutAuth<PasskeyBridgeStartResponse>(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.header('origin', origin)
|
||||
.body({purpose: 'login', runner: 'page', nonce_hash: nonce.nonceHash, ...body})
|
||||
.execute();
|
||||
return {ceremonyId: start.ceremony_id, bridgeUrl: start.bridge_url, nonce};
|
||||
}
|
||||
|
||||
async function startSudo(token: string, runner: 'page' | 'native' = 'page'): Promise<StartedBridge> {
|
||||
const nonce = createBridgeNonce();
|
||||
const start = await createBuilder<PasskeyBridgeStartResponse>(harness, token)
|
||||
.post('/users/@me/passkey-bridge')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({runner, nonce_hash: nonce.nonceHash})
|
||||
.execute();
|
||||
return {ceremonyId: start.ceremony_id, bridgeUrl: start.bridge_url, nonce};
|
||||
}
|
||||
|
||||
async function fetchOptions(ceremonyId: string, origin = LEGACY_ORIGIN): Promise<WebAuthnAuthenticationOptions> {
|
||||
const {options} = await createBuilderWithoutAuth<{options: WebAuthnAuthenticationOptions}>(harness)
|
||||
.post(`/auth/passkey-bridge/${ceremonyId}/options`)
|
||||
.header('origin', origin)
|
||||
.execute();
|
||||
return options;
|
||||
}
|
||||
|
||||
async function complete(
|
||||
ceremonyId: string,
|
||||
device: WebAuthnDevice,
|
||||
origin = LEGACY_ORIGIN,
|
||||
): Promise<PasskeyBridgeFinishResponse> {
|
||||
const options = await fetchOptions(ceremonyId, origin);
|
||||
return createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${ceremonyId}/complete`)
|
||||
.header('origin', origin)
|
||||
.body({response: createAuthenticationResponse(device, options)})
|
||||
.execute();
|
||||
}
|
||||
|
||||
function completionCodeFrom(finish: PasskeyBridgeFinishResponse, ceremonyId: string): string {
|
||||
const url = new URL(finish.return_url!);
|
||||
const [id, code] = url.hash.slice('#passkey-bridge='.length).split('.');
|
||||
expect(id).toBe(ceremonyId);
|
||||
return code;
|
||||
}
|
||||
|
||||
function redeemLogin(ceremonyId: string, nonce: string, completionCode: string) {
|
||||
return createBuilderWithoutAuth<PasskeyBridgeLoginRedeemResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${ceremonyId}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({nonce, completion_code: completionCode});
|
||||
}
|
||||
|
||||
it('refuses to start outside the new origin and on a self-hosted instance, whatever the switch', async () => {
|
||||
const nonce = createBridgeNonce();
|
||||
const body = {purpose: 'login', runner: 'native', nonce_hash: nonce.nonceHash};
|
||||
for (const origin of [LEGACY_ORIGIN, 'https://evil.example']) {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.header('origin', origin)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
}
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
getConfig().instance.selfHosted = true;
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
getConfig().instance.selfHosted = false;
|
||||
await setDomainMigration(false);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/passkey-bridge')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('runs the ceremony only on the paired origin and keeps going when the switch goes off', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
expect(started.bridgeUrl).toBe(`${LEGACY_ORIGIN}/passkey-bridge#${started.ceremonyId}`);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/options`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
const options = await fetchOptions(started.ceremonyId);
|
||||
expect(options.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(options.allowCredentials).toBeUndefined();
|
||||
expect(options.userVerification).toBe('required');
|
||||
await setDomainMigration(false);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(device, options)})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('signs in through a page ceremony and always returns to the bridge page', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const started = await startLogin({
|
||||
return_path: '/api/v1/oauth2/authorize?prompt=none&redirect_uri=https://evil.example/cb',
|
||||
});
|
||||
const finish = await complete(started.ceremonyId, device);
|
||||
expect(finish.completion_code).toBeNull();
|
||||
const returnUrl = new URL(finish.return_url!);
|
||||
expect(`${returnUrl.origin}${returnUrl.pathname}${returnUrl.search}`).toBe(`${TARGET_ORIGIN}/passkey-bridge`);
|
||||
const code = completionCodeFrom(finish, started.ceremonyId);
|
||||
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
if (redeemed.status !== 'completed') return;
|
||||
expect(redeemed.user_id).toBe(account.userId);
|
||||
const me = await createBuilder<{id: string}>(harness, redeemed.token).get('/users/@me').execute();
|
||||
expect(me.id).toBe(account.userId);
|
||||
await redeemLogin(started.ceremonyId, started.nonce.nonce, code)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('needs both the nonce and the completion code', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
|
||||
const attacker = createBridgeNonce();
|
||||
await redeemLogin(started.ceremonyId, attacker.nonce, code)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_PASSKEY_BRIDGE_NONCE)
|
||||
.execute();
|
||||
await redeemLogin(started.ceremonyId, started.nonce.nonce, code)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
|
||||
const second = await startLogin();
|
||||
completionCodeFrom(await complete(second.ceremonyId, device), second.ceremonyId);
|
||||
await redeemLogin(second.ceremonyId, second.nonce.nonce, 'A'.repeat(43))
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_PASSKEY_BRIDGE_NONCE)
|
||||
.execute();
|
||||
await redeemLogin(second.ceremonyId, second.nonce.nonce, 'A'.repeat(43))
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('keeps a pending ceremony when redeemed early or verification fails', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
|
||||
const started = await startLogin();
|
||||
await redeemLogin(started.ceremonyId, started.nonce.nonce, 'A'.repeat(43))
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
const options = await fetchOptions(started.ceremonyId);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(target, options)})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
|
||||
.execute();
|
||||
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
|
||||
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('never lets a bridge challenge through the normal endpoints', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
const options = await fetchOptions(started.ceremonyId);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(device, options), challenge: options.challenge})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('reports a cancelled ceremony and refuses to cancel a completed one', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
const cancelled = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/cancel`)
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.execute();
|
||||
const code = completionCodeFrom(cancelled, started.ceremonyId);
|
||||
expect(await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute()).toEqual({status: 'cancelled'});
|
||||
|
||||
const second = await startLogin();
|
||||
await complete(second.ceremonyId, device);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/auth/passkey-bridge/${second.ceremonyId}/cancel`)
|
||||
.header('origin', LEGACY_ORIGIN)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('completes two-factor sign in for the ticket holder', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerPasskey(
|
||||
harness,
|
||||
account.token,
|
||||
device,
|
||||
{mfa_method: 'totp', mfa_code: generateTotpCode(secret)},
|
||||
'Old',
|
||||
);
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {mfa_method: 'totp', mfa_code: generateTotpCode(secret)});
|
||||
const login = (await loginUser(harness, {email: account.email, password: account.password})) as LoginMfaResponse;
|
||||
const started = await startLogin({purpose: 'login_mfa', ticket: login.ticket});
|
||||
const options = await fetchOptions(started.ceremonyId);
|
||||
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([device.credentialId.toString('base64url')]);
|
||||
expect(options.userVerification).toBe('discouraged');
|
||||
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
|
||||
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({code: generateTotpCode(secret), ticket: login.ticket})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('issues a sudo token that passes a sudo-protected route', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const credentialId = device.credentialId.toString('base64url');
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
|
||||
.body({name: 'Renamed'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
const redeemed = await runNativeSudoBridge(harness, account.token, device);
|
||||
expect(redeemed.status).toBe('completed');
|
||||
if (redeemed.status !== 'completed') return;
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
|
||||
.header(SUDO_MODE_HEADER, redeemed.sudo_token)
|
||||
.body({name: 'Renamed'})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns sudo page ceremonies to the bridge page on the new origin', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const started = await startSudo(account.token);
|
||||
const finish = await complete(started.ceremonyId, device);
|
||||
const returnUrl = new URL(finish.return_url!);
|
||||
expect(`${returnUrl.origin}${returnUrl.pathname}`).toBe(`${TARGET_ORIGIN}/passkey-bridge`);
|
||||
const code = completionCodeFrom(finish, started.ceremonyId);
|
||||
const redeemed = await createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, account.token)
|
||||
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({nonce: started.nonce.nonce, completion_code: code})
|
||||
.execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('does not consume a ceremony redeemed on the wrong route or by another user', async () => {
|
||||
const {account, device} = await createLegacyAccount();
|
||||
const other = await createTestAccount(harness);
|
||||
const started = await startSudo(account.token, 'native');
|
||||
const options = await fetchOptions(started.ceremonyId, TARGET_ORIGIN);
|
||||
const finish = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(device, options)})
|
||||
.execute();
|
||||
const body = {nonce: started.nonce.nonce, completion_code: finish.completion_code};
|
||||
await redeemLogin(started.ceremonyId, body.nonce, body.completion_code!)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
await createBuilder(harness, other.token)
|
||||
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
|
||||
.execute();
|
||||
const redeemed = await createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, account.token)
|
||||
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body(body)
|
||||
.execute();
|
||||
expect(redeemed.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('always stores the ceremony with an expiry', async () => {
|
||||
const {device} = await createLegacyAccount();
|
||||
const started = await startLogin();
|
||||
const key = `passkey_bridge:${createHash('sha256').update(started.ceremonyId).digest('hex')}`;
|
||||
const cache = getCacheService();
|
||||
const ttls = [await cache.ttl(key)];
|
||||
await fetchOptions(started.ceremonyId);
|
||||
ttls.push(await cache.ttl(key));
|
||||
await complete(started.ceremonyId, device);
|
||||
ttls.push(await cache.ttl(key));
|
||||
for (const ttl of ttls) {
|
||||
expect(ttl).toBeGreaterThan(0);
|
||||
expect(ttl).toBeLessThanOrEqual(600);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
LEGACY_RP_ID,
|
||||
listPasskeys,
|
||||
registerPasskey,
|
||||
runNativeSudoBridge,
|
||||
setDomainMigration,
|
||||
TARGET_ORIGIN,
|
||||
TARGET_RP_ID,
|
||||
} from '@app/api/auth/tests/PasskeyTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
createWebAuthnDevice,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {PasskeyMigrationResponse} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const MIGRATION_PATH = '/users/@me/mfa/webauthn/migration';
|
||||
const MIGRATION_OPTIONS_PATH = '/users/@me/mfa/webauthn/migration/registration-options';
|
||||
|
||||
function credentialIdOf(device: WebAuthnDevice): string {
|
||||
return device.credentialId.toString('base64url');
|
||||
}
|
||||
|
||||
interface RpcSessionResponse {
|
||||
data: {webauthn_credentials: Array<{id: string; rp_id: string}>};
|
||||
}
|
||||
|
||||
describe('Passkey migration', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function createAssignedAccount(): Promise<{account: TestAccount; legacy: WebAuthnDevice}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const legacy = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Laptop');
|
||||
await setDomainMigration(true, [account.userId]);
|
||||
return {account, legacy};
|
||||
}
|
||||
|
||||
async function getPending(token: string): Promise<PasskeyMigrationResponse['pending']> {
|
||||
const response = await createBuilder<PasskeyMigrationResponse>(harness, token).get(MIGRATION_PATH).execute();
|
||||
return response.pending;
|
||||
}
|
||||
|
||||
async function migrationOptions(token: string): Promise<WebAuthnRegistrationOptions> {
|
||||
return createBuilder<WebAuthnRegistrationOptions>(harness, token)
|
||||
.post(MIGRATION_OPTIONS_PATH)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.execute();
|
||||
}
|
||||
|
||||
function completeMigration(token: string, device: WebAuthnDevice, options: WebAuthnRegistrationOptions) {
|
||||
return createBuilder(harness, token)
|
||||
.post(MIGRATION_PATH)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createRegistrationResponse(device, options, 'Laptop'), challenge: options.challenge});
|
||||
}
|
||||
|
||||
async function migrate(account: TestAccount, legacy: WebAuthnDevice): Promise<WebAuthnDevice> {
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
const target = createWebAuthnDevice();
|
||||
await completeMigration(account.token, target, await migrationOptions(account.token))
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
return target;
|
||||
}
|
||||
|
||||
async function discoverableLogin(device: WebAuthnDevice, origin?: string, status: number = HTTP_STATUS.OK) {
|
||||
const optionsBuilder = createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/webauthn/authentication-options')
|
||||
.body(null);
|
||||
if (origin) optionsBuilder.header('origin', origin);
|
||||
const options = await optionsBuilder.execute();
|
||||
const builder = createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.body({response: createAuthenticationResponse(device, options), challenge: options.challenge})
|
||||
.expect(status);
|
||||
if (origin) builder.header('origin', origin);
|
||||
await builder.execute();
|
||||
}
|
||||
|
||||
it('records a pending update for any account on the new origin while the switch is on', async () => {
|
||||
const unassigned = await createTestAccount(harness);
|
||||
const unassignedDevice = createWebAuthnDevice();
|
||||
await registerPasskey(harness, unassigned.token, unassignedDevice, {password: unassigned.password}, 'Laptop');
|
||||
await setDomainMigration(false);
|
||||
expect((await runNativeSudoBridge(harness, unassigned.token, unassignedDevice)).status).toBe('completed');
|
||||
expect(await getPending(unassigned.token)).toBeNull();
|
||||
|
||||
await setDomainMigration(true);
|
||||
expect((await runNativeSudoBridge(harness, unassigned.token, unassignedDevice)).status).toBe('completed');
|
||||
expect(await getPending(unassigned.token)).toEqual({
|
||||
credential_id: credentialIdOf(unassignedDevice),
|
||||
name: 'Laptop',
|
||||
cross_device: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('needs a pending update and the new origin for registration options', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
await createBuilder(harness, account.token)
|
||||
.post(MIGRATION_OPTIONS_PATH)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_MIGRATION)
|
||||
.execute();
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(MIGRATION_OPTIONS_PATH)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_MIGRATION)
|
||||
.execute();
|
||||
const options = await migrationOptions(account.token);
|
||||
expect(options.rp.id).toBe(TARGET_RP_ID);
|
||||
});
|
||||
|
||||
it('replaces the passkey under the same name and hides the old one', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
const target = await migrate(account, legacy);
|
||||
const credentials = await listPasskeys(harness, account.token);
|
||||
expect(credentials).toEqual([
|
||||
expect.objectContaining({id: credentialIdOf(target), name: 'Laptop', rp_id: TARGET_RP_ID}),
|
||||
]);
|
||||
const old = await getUserRepository().getWebAuthnCredential(
|
||||
createUserID(BigInt(account.userId)),
|
||||
credentialIdOf(legacy),
|
||||
);
|
||||
expect(old?.supersededBy).toBe(credentialIdOf(target));
|
||||
expect(await getPending(account.token)).toBeNull();
|
||||
const ready = await createBuilder<RpcSessionResponse>(harness, '')
|
||||
.post('/test/rpc-session-init')
|
||||
.body({type: 'session', token: account.token, version: 1, ip: '127.0.0.1'})
|
||||
.execute();
|
||||
expect(ready.data.webauthn_credentials.map(({id, rp_id}) => ({id, rp_id}))).toEqual([
|
||||
{id: credentialIdOf(target), rp_id: TARGET_RP_ID},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the old passkey working off the new origin', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
const target = await migrate(account, legacy);
|
||||
await discoverableLogin(legacy);
|
||||
await discoverableLogin(legacy, TARGET_ORIGIN, HTTP_STATUS.UNAUTHORIZED);
|
||||
await discoverableLogin(target, TARGET_ORIGIN);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(legacy)}`)
|
||||
.body({name: 'Renamed', password: account.password})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_WEBAUTHN_CREDENTIAL)
|
||||
.execute();
|
||||
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, account.token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
expect(sudoOptions.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(sudoOptions.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
|
||||
});
|
||||
|
||||
it('removes the old passkey together with its replacement', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
const target = await migrate(account, legacy);
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(target)}`)
|
||||
.body({password: account.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
expect(await getUserRepository().listWebAuthnCredentials(createUserID(BigInt(account.userId)))).toEqual([]);
|
||||
await discoverableLogin(legacy, undefined, HTTP_STATUS.UNAUTHORIZED);
|
||||
});
|
||||
|
||||
it('removes every remaining superseded passkey with the last visible one', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const orphan = createWebAuthnDevice();
|
||||
const visible = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, orphan, {password: account.password}, 'Orphan');
|
||||
await registerPasskey(harness, account.token, visible, {password: account.password}, 'Visible');
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await getUserRepository().setWebAuthnCredentialSupersededBy(userId, credentialIdOf(orphan), 'gone');
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
|
||||
.body({password: account.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
expect(await getUserRepository().listWebAuthnCredentials(userId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no way to attach the old passkey to another one', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, target, {password: account.password}, 'Phone', TARGET_ORIGIN);
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(MIGRATION_PATH)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
expect((await listPasskeys(harness, account.token)).map((cred) => cred.id).sort()).toEqual(
|
||||
[credentialIdOf(legacy), credentialIdOf(target)].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('never lets a migration challenge through the normal registration route', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
const options = await migrationOptions(account.token);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({
|
||||
response: createRegistrationResponse(createWebAuthnDevice(), options, 'Sneaky'),
|
||||
challenge: options.challenge,
|
||||
name: 'Sneaky',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('creates one credential when two updates race', async () => {
|
||||
const {account, legacy} = await createAssignedAccount();
|
||||
await runNativeSudoBridge(harness, account.token, legacy);
|
||||
const first = await migrationOptions(account.token);
|
||||
const second = await migrationOptions(account.token);
|
||||
const results = await Promise.all(
|
||||
[first, second].map((options) =>
|
||||
completeMigration(account.token, createWebAuthnDevice(), options)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.executeWithResponse()
|
||||
.then(
|
||||
() => 'ok',
|
||||
() => 'failed',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(results.sort()).toEqual(['failed', 'ok']);
|
||||
const credentials = await listPasskeys(harness, account.token);
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0].rp_id).toBe(TARGET_RP_ID);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
LEGACY_ORIGIN,
|
||||
LEGACY_RP_ID,
|
||||
listPasskeys,
|
||||
registerPasskey,
|
||||
TARGET_ORIGIN,
|
||||
TARGET_RP_ID,
|
||||
} from '@app/api/auth/tests/PasskeyTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createWebAuthnDevice,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
function credentialIdOf(device: WebAuthnDevice): string {
|
||||
return device.credentialId.toString('base64url');
|
||||
}
|
||||
|
||||
describe('Passkey relying party selection', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().instance.selfHosted = false;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function registrationRpId(account: TestAccount, origin?: string): Promise<string> {
|
||||
const builder = createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({password: account.password});
|
||||
if (origin) builder.header('origin', origin);
|
||||
return (await builder.execute()).rp.id;
|
||||
}
|
||||
|
||||
async function discoverableOptions(origin?: string): Promise<WebAuthnAuthenticationOptions> {
|
||||
const builder = createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/webauthn/authentication-options')
|
||||
.body(null);
|
||||
if (origin) builder.header('origin', origin);
|
||||
return builder.execute();
|
||||
}
|
||||
|
||||
async function sudoOptions(token: string, origin?: string): Promise<WebAuthnAuthenticationOptions> {
|
||||
const builder = createBuilder<WebAuthnAuthenticationOptions>(harness, token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null);
|
||||
if (origin) builder.header('origin', origin);
|
||||
return builder.execute();
|
||||
}
|
||||
|
||||
async function createMixedAccount(): Promise<{account: TestAccount; legacy: WebAuthnDevice; target: WebAuthnDevice}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const legacy = createWebAuthnDevice();
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Old');
|
||||
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
|
||||
return {account, legacy, target};
|
||||
}
|
||||
|
||||
it('uses the new relying party only for requests from the new origin', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
expect(await registrationRpId(account)).toBe(LEGACY_RP_ID);
|
||||
expect(await registrationRpId(account, LEGACY_ORIGIN)).toBe(LEGACY_RP_ID);
|
||||
expect(await registrationRpId(account, TARGET_ORIGIN)).toBe(TARGET_RP_ID);
|
||||
expect((await discoverableOptions()).rpId).toBe(LEGACY_RP_ID);
|
||||
expect((await discoverableOptions(LEGACY_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
|
||||
expect((await discoverableOptions(TARGET_ORIGIN)).rpId).toBe(TARGET_RP_ID);
|
||||
});
|
||||
|
||||
it('keeps the legacy relying party everywhere on a self-hosted instance', async () => {
|
||||
getConfig().instance.selfHosted = true;
|
||||
const account = await createTestAccount(harness);
|
||||
expect(await registrationRpId(account, TARGET_ORIGIN)).toBe(LEGACY_RP_ID);
|
||||
expect((await discoverableOptions(TARGET_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
|
||||
});
|
||||
|
||||
it('stores and exposes the relying party of each passkey', async () => {
|
||||
const {account, legacy, target} = await createMixedAccount();
|
||||
const credentials = await listPasskeys(harness, account.token);
|
||||
expect(credentials.map(({id, rp_id}) => ({id, rp_id}))).toEqual(
|
||||
expect.arrayContaining([
|
||||
{id: credentialIdOf(legacy), rp_id: LEGACY_RP_ID},
|
||||
{id: credentialIdOf(target), rp_id: TARGET_RP_ID},
|
||||
]),
|
||||
);
|
||||
const legacyRow = await getUserRepository().getWebAuthnCredential(
|
||||
createUserID(BigInt(account.userId)),
|
||||
credentialIdOf(legacy),
|
||||
);
|
||||
expect(legacyRow?.rpId).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the legacy options unchanged for a legacy-only account off the new origin', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, device, {password: account.password}, 'Old');
|
||||
for (const origin of [undefined, LEGACY_ORIGIN]) {
|
||||
const options = await sudoOptions(account.token, origin);
|
||||
expect(options.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(device)]);
|
||||
expect(options.userVerification).toBe('discouraged');
|
||||
}
|
||||
});
|
||||
|
||||
it('offers one relying party group per request', async () => {
|
||||
const {account, legacy, target} = await createMixedAccount();
|
||||
const onTarget = await sudoOptions(account.token, TARGET_ORIGIN);
|
||||
expect(onTarget.rpId).toBe(TARGET_RP_ID);
|
||||
expect(onTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
|
||||
const offTarget = await sudoOptions(account.token);
|
||||
expect(offTarget.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(offTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
|
||||
});
|
||||
|
||||
it('falls back to the other group when the preferred one is empty', async () => {
|
||||
const legacyOnly = await createTestAccount(harness);
|
||||
const legacy = createWebAuthnDevice();
|
||||
await registerPasskey(harness, legacyOnly.token, legacy, {password: legacyOnly.password}, 'Old');
|
||||
expect((await sudoOptions(legacyOnly.token, TARGET_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
|
||||
const targetOnly = await createTestAccount(harness);
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, targetOnly.token, target, {password: targetOnly.password}, 'New', TARGET_ORIGIN);
|
||||
expect((await sudoOptions(targetOnly.token)).rpId).toBe(TARGET_RP_ID);
|
||||
});
|
||||
|
||||
it('rejects a passkey from another relying party before the test mode shortcut', async () => {
|
||||
const {legacy} = await createMixedAccount();
|
||||
const options = await discoverableOptions(TARGET_ORIGIN);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(legacy, options), challenge: options.challenge})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects a passkey outside the offered list before the test mode shortcut', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const visible = createWebAuthnDevice();
|
||||
const superseded = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, visible, {password: account.password}, 'Visible');
|
||||
await registerPasskey(harness, account.token, superseded, {password: account.password}, 'Superseded');
|
||||
await getUserRepository().setWebAuthnCredentialSupersededBy(
|
||||
createUserID(BigInt(account.userId)),
|
||||
credentialIdOf(superseded),
|
||||
credentialIdOf(visible),
|
||||
);
|
||||
const options = await sudoOptions(account.token, TARGET_ORIGIN);
|
||||
expect(options.rpId).toBe(LEGACY_RP_ID);
|
||||
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(visible)]);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({
|
||||
name: 'Renamed',
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: createAuthenticationResponse(superseded, options),
|
||||
webauthn_challenge: options.challenge,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
const retry = await sudoOptions(account.token, TARGET_ORIGIN);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({
|
||||
name: 'Renamed',
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: createAuthenticationResponse(visible, retry),
|
||||
webauthn_challenge: retry.challenge,
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('accepts a superseded passkey only off the new origin', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const legacy = createWebAuthnDevice();
|
||||
const target = createWebAuthnDevice();
|
||||
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Old');
|
||||
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
|
||||
await getUserRepository().setWebAuthnCredentialSupersededBy(
|
||||
createUserID(BigInt(account.userId)),
|
||||
credentialIdOf(legacy),
|
||||
credentialIdOf(target),
|
||||
);
|
||||
expect((await listPasskeys(harness, account.token)).map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
|
||||
|
||||
const offTarget = await discoverableOptions();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.body({response: createAuthenticationResponse(legacy, offTarget), challenge: offTarget.challenge})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const sudoOffTarget = await sudoOptions(account.token);
|
||||
expect(sudoOffTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
|
||||
|
||||
const onTarget = await discoverableOptions(TARGET_ORIGIN);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(legacy, onTarget), challenge: onTarget.challenge})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
|
||||
.execute();
|
||||
const sudoOnTarget = await sudoOptions(account.token, TARGET_ORIGIN);
|
||||
expect(sudoOnTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash, randomBytes} from 'node:crypto';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {DEFAULT_DOMAIN_MIGRATION_CONFIG} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import type {
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
|
||||
export const TARGET_ORIGIN = 'https://fluxer.com';
|
||||
export const LEGACY_ORIGIN = 'https://web.fluxer.app';
|
||||
export const LEGACY_RP_ID = 'localhost';
|
||||
export const TARGET_RP_ID = 'fluxer.com';
|
||||
|
||||
export interface PasskeyCredentialListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
rp_id: string;
|
||||
}
|
||||
|
||||
export interface BridgeNonce {
|
||||
nonce: string;
|
||||
nonceHash: string;
|
||||
}
|
||||
|
||||
export function createBridgeNonce(): BridgeNonce {
|
||||
const nonce = randomBytes(32).toString('base64url');
|
||||
return {nonce, nonceHash: createHash('sha256').update(nonce).digest('hex')};
|
||||
}
|
||||
|
||||
export async function setDomainMigration(enabled: boolean, includedUserIds: Array<string> = []): Promise<void> {
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled,
|
||||
included_user_ids: includedUserIds,
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerPasskey(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
device: WebAuthnDevice,
|
||||
sudo: Record<string, unknown>,
|
||||
name: string,
|
||||
origin?: string,
|
||||
): Promise<void> {
|
||||
const optionsBuilder = createBuilder<WebAuthnRegistrationOptions>(harness, token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body(sudo);
|
||||
if (origin) optionsBuilder.header('origin', origin);
|
||||
const options = await optionsBuilder.execute();
|
||||
const registerBuilder = createBuilder(harness, token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({response: createRegistrationResponse(device, options, name), challenge: options.challenge, name})
|
||||
.expect(204);
|
||||
if (origin) registerBuilder.header('origin', origin);
|
||||
await registerBuilder.execute();
|
||||
}
|
||||
|
||||
export async function listPasskeys(harness: ApiTestHarness, token: string): Promise<Array<PasskeyCredentialListItem>> {
|
||||
return createBuilder<Array<PasskeyCredentialListItem>>(harness, token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function runNativeSudoBridge(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
device: WebAuthnDevice,
|
||||
nonce: BridgeNonce = createBridgeNonce(),
|
||||
): Promise<PasskeyBridgeSudoRedeemResponse> {
|
||||
const start = await createBuilder<PasskeyBridgeStartResponse>(harness, token)
|
||||
.post('/users/@me/passkey-bridge')
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({runner: 'native', nonce_hash: nonce.nonceHash})
|
||||
.execute();
|
||||
const {options} = await createBuilderWithoutAuth<{options: WebAuthnAuthenticationOptions}>(harness)
|
||||
.post(`/auth/passkey-bridge/${start.ceremony_id}/options`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.execute();
|
||||
const finish = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
|
||||
.post(`/auth/passkey-bridge/${start.ceremony_id}/complete`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({response: createAuthenticationResponse(device, options)})
|
||||
.execute();
|
||||
return createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, token)
|
||||
.post(`/users/@me/passkey-bridge/${start.ceremony_id}/redeem`)
|
||||
.header('origin', TARGET_ORIGIN)
|
||||
.body({nonce: nonce.nonce, completion_code: finish.completion_code})
|
||||
.execute();
|
||||
}
|
||||
@@ -100,6 +100,8 @@ export interface WebAuthnCredentialRow {
|
||||
created_at: Date;
|
||||
last_used_at: Nullish<Date>;
|
||||
version: number;
|
||||
rp_id: Nullish<string>;
|
||||
superseded_by: Nullish<string>;
|
||||
}
|
||||
|
||||
export interface EmailChangeTicketRow {
|
||||
@@ -193,6 +195,8 @@ export const WEBAUTHN_CREDENTIAL_COLUMNS = [
|
||||
'created_at',
|
||||
'last_used_at',
|
||||
'version',
|
||||
'rp_id',
|
||||
'superseded_by',
|
||||
] as const satisfies ReadonlyArray<keyof WebAuthnCredentialRow>;
|
||||
|
||||
export interface PhoneTokenRow {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {entityTagMatches} from '@app/api/utils/EntityTag';
|
||||
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||
import {resolveAltchaCaptchaAssignment} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
@@ -29,10 +30,11 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, voiceConfig, domainMigrationConfig] = await Promise.all([
|
||||
const [delivery, voiceConfig, domainMigrationConfig, altchaCaptchaConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
@@ -41,6 +43,7 @@ export function ExperimentController(app: HonoApp) {
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId),
|
||||
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -6,6 +6,10 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
@@ -57,6 +61,7 @@ describe('GET /experiments', () => {
|
||||
assignments: {
|
||||
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -134,6 +139,62 @@ describe('GET /experiments', () => {
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('resolves the altcha captcha caller through the allowlist and the exclusion list', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
anonymous_enabled: true,
|
||||
included_user_ids: [targeted.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
|
||||
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(excludedBody.assignments.altcha_captcha).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('bumps the altcha captcha config version on every admin update without the client sending one', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{altcha_captcha: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({altcha_captcha: {enabled: true, included_user_ids: [admin.userId]}})
|
||||
.execute();
|
||||
expect(afterFirst.altcha_captcha).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{
|
||||
altcha_captcha: {config_version: number; anonymous_enabled: boolean; cost: number; max_counter: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({altcha_captcha: {anonymous_enabled: true, cost: 2000, max_counter: 400}})
|
||||
.execute();
|
||||
expect(afterSecond.altcha_captcha).toMatchObject({
|
||||
config_version: 2,
|
||||
anonymous_enabled: true,
|
||||
cost: 2000,
|
||||
max_counter: 400,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setExperimentDeliveryConfig({
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
type PendingRegistrationResponse,
|
||||
type RegistrationUrlResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
type AltchaCaptchaConfig,
|
||||
AltchaCaptchaConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {
|
||||
type DomainMigrationConfig,
|
||||
DomainMigrationConfigSchema,
|
||||
@@ -68,6 +72,7 @@ const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
@@ -376,6 +381,7 @@ type StoredConfigSection =
|
||||
| 'voice noise suppression'
|
||||
| 'push service delivery'
|
||||
| 'domain migration'
|
||||
| 'altcha captcha'
|
||||
| 'experiment delivery'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
@@ -522,6 +528,10 @@ function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationCo
|
||||
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
|
||||
}
|
||||
|
||||
function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig {
|
||||
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
|
||||
}
|
||||
|
||||
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
|
||||
}
|
||||
@@ -1171,6 +1181,7 @@ export class InstanceConfigRepository {
|
||||
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
|
||||
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
|
||||
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
|
||||
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
@@ -1305,6 +1316,23 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getAltchaCaptchaConfig(): Promise<AltchaCaptchaConfig> {
|
||||
const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY);
|
||||
return parseStoredAltchaCaptchaConfig(raw);
|
||||
}
|
||||
|
||||
async setAltchaCaptchaConfig(config: AltchaCaptchaConfig): Promise<void> {
|
||||
await this.updateAltchaCaptchaConfig(() => config);
|
||||
}
|
||||
|
||||
updateAltchaCaptchaConfig(
|
||||
update: (current: AltchaCaptchaConfig) => AltchaCaptchaConfig,
|
||||
): Promise<AltchaCaptchaConfig> {
|
||||
return this.updateStoredConfig(ALTCHA_CAPTCHA_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(AltchaCaptchaConfigSchema, update(parseStoredAltchaCaptchaConfig(raw)), 'altcha captcha'),
|
||||
);
|
||||
}
|
||||
|
||||
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
|
||||
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredExperimentDeliveryConfig(raw);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHmac} from 'node:crypto';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {accountPolicyContactHasCapability} from '@app/api/risk/AccountPolicyService';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
@@ -9,12 +12,43 @@ import {Headers} from '@fluxer/constants/src/Headers';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
|
||||
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {type AltchaCaptchaConfig, altchaCaptchaAppliesTo} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import type {InstanceCaptchaProvider} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory';
|
||||
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {AltchaProvider} from '@pkgs/captcha/src/providers/AltchaProvider';
|
||||
import type {Context} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
|
||||
const ALTCHA_SPENT_CHALLENGE_KEY_PREFIX = 'captcha:altcha:spent:';
|
||||
|
||||
function deriveAltchaSecret(label: string): string {
|
||||
return createHmac('sha256', Config.auth.sudoModeSecret).update(label).digest('hex');
|
||||
}
|
||||
|
||||
function createAltchaProvider(config: AltchaCaptchaConfig): AltchaProvider {
|
||||
return new AltchaProvider({
|
||||
hmacSignatureSecret: deriveAltchaSecret('fluxer-altcha-challenge-signature-v1'),
|
||||
hmacKeySignatureSecret: deriveAltchaSecret('fluxer-altcha-key-signature-v1'),
|
||||
cost: config.cost,
|
||||
maxCounter: config.max_counter,
|
||||
claimChallenge: (signature, ttlSeconds) =>
|
||||
getKVClient().setnx(`${ALTCHA_SPENT_CHALLENGE_KEY_PREFIX}${signature}`, '1', ttlSeconds),
|
||||
logger: Logger,
|
||||
});
|
||||
}
|
||||
|
||||
async function altchaChallengeData(altcha: AltchaProvider | null): Promise<Record<string, unknown> | undefined> {
|
||||
if (!altcha) return undefined;
|
||||
return {captcha_provider: 'altcha', altcha_challenge: await altcha.createChallenge()};
|
||||
}
|
||||
|
||||
async function resolveAltchaProvider(ctx: Context<HonoEnv>, user: User | undefined): Promise<AltchaProvider | null> {
|
||||
const config = await ctx.get('instanceConfigRepository').getAltchaCaptchaConfig();
|
||||
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null)) return null;
|
||||
return createAltchaProvider(config);
|
||||
}
|
||||
|
||||
function resolveProviderSecret(
|
||||
config: InstanceCaptchaEffectiveConfig,
|
||||
provider: InstanceCaptchaProvider,
|
||||
@@ -58,11 +92,19 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
|
||||
if (accountPolicyContactHasCapability(user?.email, 'captcha_exempt')) return;
|
||||
if (userHasCaptchaExemptFlag(user)) return;
|
||||
if (await requestUserHasCaptchaExemptFlag(ctx)) return;
|
||||
const altcha = await resolveAltchaProvider(ctx, user);
|
||||
const token = ctx.req.header(Headers.X_CAPTCHA_TOKEN);
|
||||
if (!token) {
|
||||
throw new CaptchaRequiredError();
|
||||
throw new CaptchaRequiredError(await altchaChallengeData(altcha));
|
||||
}
|
||||
const provider = resolveCaptchaProvider(captchaConfig, ctx.req.header(Headers.X_CAPTCHA_TYPE));
|
||||
const requestedType = ctx.req.header(Headers.X_CAPTCHA_TYPE);
|
||||
if (requestedType === 'altcha') {
|
||||
if (!altcha || !(await altcha.verify({token}))) {
|
||||
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const provider = resolveCaptchaProvider(captchaConfig, requestedType);
|
||||
const isValid = await provider.verify({
|
||||
token,
|
||||
remoteIp:
|
||||
@@ -72,7 +114,7 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
|
||||
}) ?? undefined,
|
||||
});
|
||||
if (!isValid) {
|
||||
throw new InvalidCaptchaError();
|
||||
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {DEFAULT_ALTCHA_CAPTCHA_CONFIG} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -25,6 +26,7 @@ function createHarness(
|
||||
): (headers: Record<string, string>) => Promise<Response> {
|
||||
const repository = {
|
||||
getEffectiveCaptchaConfig: async () => captcha,
|
||||
getAltchaCaptchaConfig: async () => DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
} as unknown as InstanceConfigRepository;
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ export class WebAuthnCredential {
|
||||
readonly createdAt: Date;
|
||||
readonly lastUsedAt: Date | null;
|
||||
readonly version: number;
|
||||
readonly rpId: string | null;
|
||||
readonly supersededBy: string | null;
|
||||
|
||||
constructor(row: WebAuthnCredentialRow) {
|
||||
this.credentialId = row.credential_id;
|
||||
@@ -22,6 +24,8 @@ export class WebAuthnCredential {
|
||||
this.createdAt = row.created_at;
|
||||
this.lastUsedAt = row.last_used_at ?? null;
|
||||
this.version = row.version;
|
||||
this.rpId = row.rp_id ?? null;
|
||||
this.supersededBy = row.superseded_by ?? null;
|
||||
}
|
||||
|
||||
toRow(userId: UserID): WebAuthnCredentialRow {
|
||||
@@ -35,6 +39,8 @@ export class WebAuthnCredential {
|
||||
created_at: this.createdAt,
|
||||
last_used_at: this.lastUsedAt,
|
||||
version: this.version,
|
||||
rp_id: this.rpId,
|
||||
superseded_by: this.supersededBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1053,6 +1053,292 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/passkey-bridge": {
|
||||
"post": {
|
||||
"operationId": "start_passkey_bridge_login",
|
||||
"summary": "Start passkey bridge sign in",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeStartResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Start a sign in or two-factor ceremony for a passkey that belongs to the paired first-party origin. Only available on the official instance from the new origin.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeLoginStartRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/passkey-bridge/{ceremony_id}/cancel": {
|
||||
"post": {
|
||||
"operationId": "cancel_passkey_bridge",
|
||||
"summary": "Cancel passkey bridge",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeFinishResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Cancel a passkey bridge ceremony that has not completed.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ceremony_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
},
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/passkey-bridge/{ceremony_id}/complete": {
|
||||
"post": {
|
||||
"operationId": "complete_passkey_bridge",
|
||||
"summary": "Complete passkey bridge",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeFinishResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Verify the WebAuthn response for a pending passkey bridge ceremony. A failed verification leaves the ceremony pending so it can be retried.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ceremony_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
},
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeCompleteRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/passkey-bridge/{ceremony_id}/options": {
|
||||
"post": {
|
||||
"operationId": "get_passkey_bridge_options",
|
||||
"summary": "Get passkey bridge options",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeOptionsResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Issue WebAuthn authentication options for a pending passkey bridge ceremony. The request must come from the origin that runs the ceremony.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ceremony_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
},
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/passkey-bridge/{ceremony_id}/redeem": {
|
||||
"post": {
|
||||
"operationId": "redeem_passkey_bridge_login",
|
||||
"summary": "Redeem passkey bridge sign in",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeLoginRedeemResponse"}}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a finished sign in or two-factor passkey bridge ceremony once. Requires the nonce kept by the starting page and the completion code handed back when the ceremony finished.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ceremony_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
},
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeRedeemRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"operationId": "register_account",
|
||||
@@ -17029,6 +17315,164 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/mfa/webauthn/migration": {
|
||||
"get": {
|
||||
"operationId": "get_webauthn_migration",
|
||||
"summary": "Get pending passkey update",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyMigrationResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Return the passkey this session can update to the new domain after using it within the last five minutes, or null.",
|
||||
"security": [{"sessionToken": []}]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "complete_webauthn_migration",
|
||||
"summary": "Complete passkey update",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"204": {"description": "No Content"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Register the replacement passkey under the name of the pending one. The old passkey stops appearing in lists and is removed together with its replacement.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyMigrationCompleteRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/mfa/webauthn/migration/registration-options": {
|
||||
"post": {
|
||||
"operationId": "get_webauthn_migration_registration_options",
|
||||
"summary": "Get passkey update registration options",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebAuthnChallengeResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Generate registration options for the passkey that replaces the pending one. Requires a pending passkey update for this session.",
|
||||
"security": [{"sessionToken": []}]
|
||||
}
|
||||
},
|
||||
"/users/@me/mfa/webauthn/two-factor": {
|
||||
"put": {
|
||||
"operationId": "set_webauthn_two_factor",
|
||||
@@ -17489,6 +17933,135 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/passkey-bridge": {
|
||||
"post": {
|
||||
"operationId": "start_passkey_bridge_sudo",
|
||||
"summary": "Start passkey bridge sudo verification",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeStartResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Start a sudo verification ceremony for a passkey that belongs to the paired first-party origin. Only available on the official instance from the new origin.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeSudoStartRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/passkey-bridge/{ceremony_id}/redeem": {
|
||||
"post": {
|
||||
"operationId": "redeem_passkey_bridge_sudo",
|
||||
"summary": "Redeem passkey bridge sudo verification",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeSudoRedeemResponse"}}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a finished sudo passkey bridge ceremony once for a sudo mode token. Requires the nonce kept by the starting page and the completion code handed back when the ceremony finished.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ceremony_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
},
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeRedeemRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/password-change/complete": {
|
||||
"post": {
|
||||
"operationId": "complete_password_change",
|
||||
@@ -22821,6 +23394,54 @@
|
||||
"required": ["token", "auth_session_id_hash"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PasskeyBridgeRedeemRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nonce": {
|
||||
"type": "string",
|
||||
"minLength": 16,
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9_-]+$",
|
||||
"description": "Nonce whose SHA-256 digest was sent when the ceremony started"
|
||||
},
|
||||
"completion_code": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Code handed back when the ceremony finished"
|
||||
}
|
||||
},
|
||||
"required": ["nonce", "completion_code"]
|
||||
},
|
||||
"PasskeyBridgeSudoRedeemResponse": {
|
||||
"oneOf": [
|
||||
{"$ref": "#/components/schemas/CancelledPasskeyBridgeSudoRedeemResponse"},
|
||||
{"$ref": "#/components/schemas/CompletedPasskeyBridgeSudoRedeemResponse"}
|
||||
]
|
||||
},
|
||||
"PasskeyBridgeSudoStartRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"runner": {"$ref": "#/components/schemas/PasskeyBridgeRunner"},
|
||||
"nonce_hash": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$",
|
||||
"description": "Lowercase hex SHA-256 digest of the nonce the starting page keeps"
|
||||
}
|
||||
},
|
||||
"required": ["runner", "nonce_hash"]
|
||||
},
|
||||
"PasskeyBridgeStartResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ceremony_id": {"type": "string", "description": "Identifier of the passkey ceremony"},
|
||||
"bridge_url": {
|
||||
"description": "Page that runs the ceremony, or null for the native runner",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
},
|
||||
"required": ["ceremony_id", "bridge_url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UserNoteUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {"note": {"description": "The note text (max 256 characters)", "type": ["string", "null"]}}
|
||||
@@ -23017,6 +23638,40 @@
|
||||
"required": ["user", "backup_codes"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PasskeyMigrationCompleteRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"response": {
|
||||
"description": "WebAuthn registration response",
|
||||
"$ref": "#/components/schemas/WebAuthnRegistrationResponse"
|
||||
},
|
||||
"challenge": {"description": "The challenge from registration options", "type": "string"}
|
||||
},
|
||||
"required": ["response", "challenge"]
|
||||
},
|
||||
"PasskeyMigrationResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pending": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credential_id": {"type": "string", "description": "ID of the passkey waiting to be updated"},
|
||||
"name": {"type": "string", "description": "User-assigned name of the passkey"},
|
||||
"cross_device": {"type": "boolean", "description": "Whether the passkey was used from another device"}
|
||||
},
|
||||
"required": ["credential_id", "name", "cross_device"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "The passkey this session can update, or null"
|
||||
}
|
||||
},
|
||||
"required": ["pending"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SudoVerificationSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -27298,7 +27953,8 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"}
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -28511,6 +29167,71 @@
|
||||
{"$ref": "#/components/schemas/AuthRegistrationPendingApprovalResponse"}
|
||||
]
|
||||
},
|
||||
"PasskeyBridgeLoginRedeemResponse": {
|
||||
"oneOf": [
|
||||
{"$ref": "#/components/schemas/CancelledPasskeyBridgeLoginRedeemResponse"},
|
||||
{"$ref": "#/components/schemas/CompletedPasskeyBridgeLoginRedeemResponse"}
|
||||
]
|
||||
},
|
||||
"PasskeyBridgeOptionsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"options": {
|
||||
"description": "WebAuthn authentication options for the ceremony",
|
||||
"$ref": "#/components/schemas/WebAuthnAuthenticationOptionsResponse"
|
||||
}
|
||||
},
|
||||
"required": ["options"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PasskeyBridgeCompleteRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"response": {
|
||||
"description": "WebAuthn authentication response",
|
||||
"$ref": "#/components/schemas/WebAuthnAuthenticationResponse"
|
||||
}
|
||||
},
|
||||
"required": ["response"]
|
||||
},
|
||||
"PasskeyBridgeFinishResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"return_url": {
|
||||
"description": "Where the page runner goes next, or null for the native runner",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"completion_code": {
|
||||
"description": "Code the native runner redeems, or null for the page runner",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
},
|
||||
"required": ["return_url", "completion_code"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PasskeyBridgeLoginStartRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"purpose": {
|
||||
"type": "string",
|
||||
"enum": ["login", "login_mfa"],
|
||||
"description": "Whether the passkey signs in or completes two-factor sign in"
|
||||
},
|
||||
"runner": {"$ref": "#/components/schemas/PasskeyBridgeRunner"},
|
||||
"ticket": {
|
||||
"description": "The MFA ticket from the login response, for login_mfa",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
},
|
||||
"nonce_hash": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$",
|
||||
"description": "Lowercase hex SHA-256 digest of the nonce the starting page keeps"
|
||||
}
|
||||
},
|
||||
"required": ["purpose", "runner", "nonce_hash"]
|
||||
},
|
||||
"OriginHandoffRedeemRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -29339,6 +30060,34 @@
|
||||
"required": ["id", "rawId", "type", "clientExtensionResults", "response"],
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"PasskeyBridgeRunner": {
|
||||
"type": "string",
|
||||
"enum": ["page", "native"],
|
||||
"description": "Where the passkey ceremony runs: a page on the paired origin or the native desktop client"
|
||||
},
|
||||
"CompletedPasskeyBridgeLoginRedeemResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string", "const": "completed", "description": "The ceremony finished"},
|
||||
"token": {"type": "string", "description": "Authentication token for API requests"},
|
||||
"user_id": {
|
||||
"description": "ID of the authenticated user",
|
||||
"$ref": "#/components/schemas/SnowflakeStringType"
|
||||
},
|
||||
"user": {
|
||||
"description": "Partial user data for the authenticated account",
|
||||
"$ref": "#/components/schemas/UserPartialResponse"
|
||||
}
|
||||
},
|
||||
"required": ["status", "token", "user_id", "user"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CancelledPasskeyBridgeLoginRedeemResponse": {
|
||||
"type": "object",
|
||||
"properties": {"status": {"type": "string", "const": "cancelled", "description": "The ceremony was cancelled"}},
|
||||
"required": ["status"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AuthRegistrationPendingApprovalResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -30881,6 +31630,12 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
|
||||
"AltchaCaptchaAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
@@ -33666,9 +34421,10 @@
|
||||
"id": {"type": "string", "description": "The credential ID"},
|
||||
"name": {"type": "string", "description": "User-assigned name for the credential"},
|
||||
"created_at": {"type": "string", "description": "When the credential was registered"},
|
||||
"last_used_at": {"description": "When the credential was last used", "type": ["string", "null"]}
|
||||
"last_used_at": {"description": "When the credential was last used", "type": ["string", "null"]},
|
||||
"rp_id": {"type": "string", "description": "Relying party ID the passkey belongs to"}
|
||||
},
|
||||
"required": ["id", "name", "created_at", "last_used_at"],
|
||||
"required": ["id", "name", "created_at", "last_used_at", "rp_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WebAuthnRegistrationResponse": {
|
||||
@@ -33716,6 +34472,21 @@
|
||||
"x-enumDescriptions": ["Time-based one-time password authenticator", "WebAuthn authenticator"]
|
||||
},
|
||||
"HexString32Type": {"type": "string", "pattern": "^[a-f0-9]{32}$"},
|
||||
"CompletedPasskeyBridgeSudoRedeemResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string", "const": "completed", "description": "The ceremony finished"},
|
||||
"sudo_token": {"type": "string", "description": "Sudo mode token"}
|
||||
},
|
||||
"required": ["status", "sudo_token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CancelledPasskeyBridgeSudoRedeemResponse": {
|
||||
"type": "object",
|
||||
"properties": {"status": {"type": "string", "const": "cancelled", "description": "The ceremony was cancelled"}},
|
||||
"required": ["status"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PhoneNumberType": {"type": "string"},
|
||||
"RelationshipTypesInput": {
|
||||
"description": "Relationship type",
|
||||
|
||||
@@ -104,6 +104,10 @@ export const AuthRateLimitConfigs = {
|
||||
bucket: 'mfa:webauthn:two_factor',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
MFA_WEBAUTHN_MIGRATION: {
|
||||
bucket: 'mfa:webauthn:migration',
|
||||
config: {limit: 20, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
PHONE_SEND_VERIFICATION: {
|
||||
bucket: 'phone:send_verification',
|
||||
config: {limit: 5, windowMs: ms('1 minute')},
|
||||
@@ -140,6 +144,26 @@ export const AuthRateLimitConfigs = {
|
||||
bucket: 'auth:origin_handoff:redeem',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_PASSKEY_BRIDGE_START: {
|
||||
bucket: 'auth:passkey_bridge:start',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_PASSKEY_BRIDGE_CEREMONY: {
|
||||
bucket: 'auth:passkey_bridge:ceremony',
|
||||
config: {limit: 20, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
AUTH_PASSKEY_BRIDGE_REDEEM: {
|
||||
bucket: 'auth:passkey_bridge:redeem',
|
||||
config: {limit: 60, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_PASSKEY_BRIDGE_START: {
|
||||
bucket: 'mfa:passkey_bridge:start',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_PASSKEY_BRIDGE_REDEEM: {
|
||||
bucket: 'mfa:passkey_bridge:redeem',
|
||||
config: {limit: 60, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
SUDO_WEBAUTHN_OPTIONS: {
|
||||
bucket: 'sudo:webauthn:options',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
|
||||
@@ -1,34 +1,69 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {parseIpBanEntry, tryParseSingleIp} from '@app/api/utils/IpRangeUtils';
|
||||
import type {IpAddressFamily} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
|
||||
let exemptDecisionKeys: ReadonlySet<string> | null = null;
|
||||
interface ExemptRange {
|
||||
family: IpAddressFamily;
|
||||
start: bigint;
|
||||
end: bigint;
|
||||
}
|
||||
|
||||
function getExemptDecisionKeys(): ReadonlySet<string> {
|
||||
if (exemptDecisionKeys) {
|
||||
return exemptDecisionKeys;
|
||||
interface IpBanExemptions {
|
||||
decisionKeys: ReadonlySet<string>;
|
||||
ranges: ReadonlyArray<ExemptRange>;
|
||||
}
|
||||
|
||||
let exemptions: IpBanExemptions | null = null;
|
||||
|
||||
function getExemptions(): IpBanExemptions {
|
||||
if (exemptions) {
|
||||
return exemptions;
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
for (const ip of Config.ipBanExemptIps) {
|
||||
const key = getSameIpDecisionKey(ip);
|
||||
if (!key) {
|
||||
throw new Error(`Invalid IP ban exemption in API config: ${ip}`);
|
||||
const decisionKeys = new Set<string>();
|
||||
const ranges: Array<ExemptRange> = [];
|
||||
for (const entry of Config.ipBanExemptIps) {
|
||||
if (entry.includes('/')) {
|
||||
const range = parseIpBanEntry(entry);
|
||||
if (range?.type !== 'range') {
|
||||
throw new Error(`Invalid IP ban exemption in API config: ${entry}`);
|
||||
}
|
||||
ranges.push({family: range.family, start: range.start, end: range.end});
|
||||
continue;
|
||||
}
|
||||
keys.add(key);
|
||||
const key = getSameIpDecisionKey(entry);
|
||||
if (!key) {
|
||||
throw new Error(`Invalid IP ban exemption in API config: ${entry}`);
|
||||
}
|
||||
decisionKeys.add(key);
|
||||
}
|
||||
exemptDecisionKeys = keys;
|
||||
return keys;
|
||||
exemptions = {decisionKeys, ranges};
|
||||
return exemptions;
|
||||
}
|
||||
|
||||
export function isIpBanExempt(ip: string | null | undefined): boolean {
|
||||
if (!ip) {
|
||||
return false;
|
||||
}
|
||||
const {decisionKeys, ranges} = getExemptions();
|
||||
const key = getSameIpDecisionKey(ip);
|
||||
return key !== null && getExemptDecisionKeys().has(key);
|
||||
if (key !== null && decisionKeys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
if (ranges.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const parsed = tryParseSingleIp(ip);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
return ranges.some(
|
||||
(range) => range.family === parsed.family && parsed.value >= range.start && parsed.value <= range.end,
|
||||
);
|
||||
}
|
||||
|
||||
export function resetIpBanExemptionsForTesting(): void {
|
||||
exemptDecisionKeys = null;
|
||||
exemptions = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {isIpBanExempt, resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('isIpBanExempt', () => {
|
||||
let originalExemptIps: Array<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
const config = getConfig();
|
||||
originalExemptIps = config.ipBanExemptIps;
|
||||
config.ipBanExemptIps = ['198.51.100.7', '2001:db8:6::', '2001:db8:1200:1000::/56', '203.0.113.0/24'];
|
||||
resetIpBanExemptionsForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().ipBanExemptIps = originalExemptIps;
|
||||
resetIpBanExemptionsForTesting();
|
||||
});
|
||||
|
||||
it('matches a bare IPv4 address exactly', () => {
|
||||
expect(isIpBanExempt('198.51.100.7')).toBe(true);
|
||||
expect(isIpBanExempt('198.51.100.8')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches a bare IPv6 address on its /64', () => {
|
||||
expect(isIpBanExempt('2001:db8:6::abcd')).toBe(true);
|
||||
expect(isIpBanExempt('2001:db8:7::1')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches every address inside a CIDR range', () => {
|
||||
expect(isIpBanExempt('2001:db8:1200:1000::1')).toBe(true);
|
||||
expect(isIpBanExempt('2001:db8:1200:10ff:ffff:ffff:ffff:ffff')).toBe(true);
|
||||
expect(isIpBanExempt('2001:db8:1200:1100::1')).toBe(false);
|
||||
expect(isIpBanExempt('2001:db8:1200:fff::1')).toBe(false);
|
||||
expect(isIpBanExempt('203.0.113.200')).toBe(true);
|
||||
expect(isIpBanExempt('::ffff:203.0.113.200')).toBe(true);
|
||||
expect(isIpBanExempt('203.0.114.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match empty or unparsable input', () => {
|
||||
expect(isIpBanExempt(null)).toBe(false);
|
||||
expect(isIpBanExempt('')).toBe(false);
|
||||
expect(isIpBanExempt('not-an-ip')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import {createHash} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {
|
||||
createChannelID,
|
||||
@@ -75,6 +76,7 @@ import {
|
||||
mapUserGuildSettingsToResponse,
|
||||
mapUserSettingsToResponse,
|
||||
mapUserToPrivateResponse,
|
||||
mapWebAuthnCredentialToResponse,
|
||||
} from '@app/api/user/UserMappers';
|
||||
import {isUserAdult} from '@app/api/utils/AgeUtils';
|
||||
import {deriveDominantAvatarColor} from '@app/api/utils/AvatarColorUtils';
|
||||
@@ -1199,12 +1201,9 @@ export class RpcService {
|
||||
longitude: geoipLongitude,
|
||||
rtc_regions: rtcRegions,
|
||||
webauthn_credentials: timeRpcStepSync(responseBuildSteps, 'map_webauthn_credentials', () =>
|
||||
userData.webAuthnCredentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
})),
|
||||
visibleWebAuthnCredentials(userData.webAuthnCredentials).map((cred) =>
|
||||
mapWebAuthnCredentialToResponse(cred, Config.auth.passkeys.rpId),
|
||||
),
|
||||
),
|
||||
version,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {Relationship} from '@app/api/models/Relationship';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@app/api/models/UserSettings';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {canUseProfileTimezone, getRequiredActions} from '@app/api/user/UserHelpers';
|
||||
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
|
||||
import type {ChannelMessageNotifications} from '@fluxer/constants/src/NotificationConstants';
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
UserFlags,
|
||||
UserPremiumTypes,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import type {WebAuthnCredentialResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import type {
|
||||
RelationshipResponse,
|
||||
UserGuildSettingsResponse,
|
||||
@@ -420,3 +422,16 @@ export function mapUserGuildSettingsToResponse(settings: UserGuildSettings): Use
|
||||
version: settings.version,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWebAuthnCredentialToResponse(
|
||||
credential: WebAuthnCredential,
|
||||
legacyRpId: string,
|
||||
): WebAuthnCredentialResponse {
|
||||
return {
|
||||
id: credential.credentialId,
|
||||
name: credential.name,
|
||||
created_at: credential.createdAt.toISOString(),
|
||||
last_used_at: credential.lastUsedAt?.toISOString() ?? null,
|
||||
rp_id: credential.rpId ?? legacyRpId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
completePasskeyMigration,
|
||||
getPasskeyMigration,
|
||||
getPasskeyMigrationRegistrationOptions,
|
||||
} from '@app/api/auth/services/PasskeyMigrationService';
|
||||
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '@app/api/middleware/AuthMiddleware';
|
||||
@@ -34,6 +39,10 @@ import {
|
||||
WebAuthnTwoFactorRequest,
|
||||
WebAuthnTwoFactorResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {
|
||||
PasskeyMigrationCompleteRequest,
|
||||
PasskeyMigrationResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
|
||||
import {CredentialIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {EmptyBodyRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
|
||||
@@ -347,7 +356,9 @@ export function UserAuthController(app: HonoApp) {
|
||||
await requireSudoMode(ctx, user, body, {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
return ctx.json(await ctx.get('userAuthRequestService').generateWebAuthnRegistrationOptions(user));
|
||||
return ctx.json(
|
||||
await ctx.get('userAuthRequestService').generateWebAuthnRegistrationOptions(user, ctx.req.header('origin')),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -433,6 +444,78 @@ export function UserAuthController(app: HonoApp) {
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/users/@me/mfa/webauthn/migration',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_MIGRATION),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'get_webauthn_migration',
|
||||
summary: 'Get pending passkey update',
|
||||
responseSchema: PasskeyMigrationResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Return the passkey this session can update to the new domain after using it within the last five minutes, or null.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await getPasskeyMigration(ctx.get('apiContext'), ctx.get('user').id, ctx.get('authSession')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/mfa/webauthn/migration/registration-options',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_MIGRATION),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'get_webauthn_migration_registration_options',
|
||||
summary: 'Get passkey update registration options',
|
||||
responseSchema: WebAuthnChallengeResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Generate registration options for the passkey that replaces the pending one. Requires a pending passkey update for this session.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await getPasskeyMigrationRegistrationOptions(
|
||||
ctx.get('apiContext'),
|
||||
ctx.get('user').id,
|
||||
ctx.get('authSession'),
|
||||
ctx.req.header('origin'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/mfa/webauthn/migration',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_MIGRATION),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', PasskeyMigrationCompleteRequest),
|
||||
OpenAPI({
|
||||
operationId: 'complete_webauthn_migration',
|
||||
summary: 'Complete passkey update',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Register the replacement passkey under the name of the pending one. The old passkey stops appearing in lists and is removed together with its replacement.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await completePasskeyMigration(
|
||||
ctx.get('apiContext'),
|
||||
ctx.get('user').id,
|
||||
ctx.get('authSession'),
|
||||
ctx.req.header('origin'),
|
||||
ctx.req.valid('json'),
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/users/@me/mfa/webauthn/two-factor',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_TWO_FACTOR),
|
||||
@@ -492,7 +575,9 @@ export function UserAuthController(app: HonoApp) {
|
||||
'Generate WebAuthn challenge for sudo mode verification using a registered security key or biometric device.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('userAuthRequestService').getSudoWebAuthnOptions(ctx.get('user')));
|
||||
return ctx.json(
|
||||
await ctx.get('userAuthRequestService').getSudoWebAuthnOptions(ctx.get('user'), ctx.req.header('origin')),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,10 +65,12 @@ export interface IUserAuthRepository {
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
rpId: string | null,
|
||||
): Promise<void>;
|
||||
updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void>;
|
||||
updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void>;
|
||||
updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void>;
|
||||
setWebAuthnCredentialSupersededBy(userId: UserID, credentialId: string, supersededBy: string): Promise<void>;
|
||||
deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void>;
|
||||
getUserIdByCredentialId(credentialId: string): Promise<UserID | null>;
|
||||
deleteAllWebAuthnCredentials(userId: UserID): Promise<void>;
|
||||
|
||||
@@ -193,8 +193,17 @@ export class UserAuthRepository implements IUserAuthRepository {
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
rpId: string | null,
|
||||
): Promise<void> {
|
||||
return this.webAuthnRepository.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
|
||||
return this.webAuthnRepository.createWebAuthnCredential(
|
||||
userId,
|
||||
credentialId,
|
||||
publicKey,
|
||||
counter,
|
||||
transports,
|
||||
name,
|
||||
rpId,
|
||||
);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
@@ -209,6 +218,10 @@ export class UserAuthRepository implements IUserAuthRepository {
|
||||
return this.webAuthnRepository.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async setWebAuthnCredentialSupersededBy(userId: UserID, credentialId: string, supersededBy: string): Promise<void> {
|
||||
return this.webAuthnRepository.setWebAuthnCredentialSupersededBy(userId, credentialId, supersededBy);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.webAuthnRepository.deleteWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
@@ -407,8 +407,9 @@ export class UserRepository implements IUserRepositoryAggregate {
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
rpId: string | null,
|
||||
): Promise<void> {
|
||||
return this.authRepo.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
|
||||
return this.authRepo.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name, rpId);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
@@ -423,6 +424,10 @@ export class UserRepository implements IUserRepositoryAggregate {
|
||||
return this.authRepo.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async setWebAuthnCredentialSupersededBy(userId: UserID, credentialId: string, supersededBy: string): Promise<void> {
|
||||
return this.authRepo.setWebAuthnCredentialSupersededBy(userId, credentialId, supersededBy);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.authRepo.deleteWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL = WebAuthnCredentials.selectCql({
|
||||
export class WebAuthnRepository {
|
||||
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
const credentials = await fetchMany<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIALS_CQL, {user_id: userId});
|
||||
return credentials.map((cred) => new WebAuthnCredential(cred));
|
||||
return credentials.filter((cred) => cred.public_key).map((cred) => new WebAuthnCredential(cred));
|
||||
}
|
||||
|
||||
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
|
||||
@@ -34,7 +34,7 @@ export class WebAuthnRepository {
|
||||
user_id: userId,
|
||||
credential_id: credentialId,
|
||||
});
|
||||
if (!cred) {
|
||||
if (!cred?.public_key) {
|
||||
return null;
|
||||
}
|
||||
return new WebAuthnCredential(cred);
|
||||
@@ -47,6 +47,7 @@ export class WebAuthnRepository {
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
rpId: string | null,
|
||||
): Promise<void> {
|
||||
const credentialData = {
|
||||
user_id: userId,
|
||||
@@ -58,6 +59,8 @@ export class WebAuthnRepository {
|
||||
created_at: new Date(),
|
||||
last_used_at: null,
|
||||
version: 1 as const,
|
||||
rp_id: rpId,
|
||||
superseded_by: null,
|
||||
};
|
||||
await upsertOne(WebAuthnCredentials.insert(credentialData));
|
||||
await upsertOne(
|
||||
@@ -101,6 +104,17 @@ export class WebAuthnRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async setWebAuthnCredentialSupersededBy(userId: UserID, credentialId: string, supersededBy: string): Promise<void> {
|
||||
await upsertOne(
|
||||
WebAuthnCredentials.patchByPk(
|
||||
{user_id: userId, credential_id: credentialId},
|
||||
{
|
||||
superseded_by: Db.set(supersededBy),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
WebAuthnCredentials.deleteByPk({
|
||||
|
||||
@@ -4,12 +4,13 @@ import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthPhone from '@app/api/auth/AuthPhone';
|
||||
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
|
||||
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import type {SudoVerificationResult} from '@app/api/auth/services/SudoVerificationService';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import * as UserAuth from '@app/api/user/services/UserAuth';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {mapUserToPrivateResponse, mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
|
||||
import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {PhoneAddNotEligibleError} from '@fluxer/errors/src/domains/auth/PhoneAddNotEligibleError';
|
||||
@@ -170,17 +171,16 @@ export class UserAuthRequestService {
|
||||
|
||||
async listWebAuthnCredentials(user: User): Promise<WebAuthnCredentialListResponse> {
|
||||
const credentials = await this.userRepository.listWebAuthnCredentials(user.id);
|
||||
return credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
}));
|
||||
const legacyRpId = this.apiContext.services.config.auth.passkeys.rpId;
|
||||
return visibleWebAuthnCredentials(credentials).map((cred) => mapWebAuthnCredentialToResponse(cred, legacyRpId));
|
||||
}
|
||||
|
||||
async generateWebAuthnRegistrationOptions(user: User): Promise<WebAuthnChallengeResponse> {
|
||||
async generateWebAuthnRegistrationOptions(
|
||||
user: User,
|
||||
origin: string | undefined,
|
||||
): Promise<WebAuthnChallengeResponse> {
|
||||
requireEmailVerified(user, 'mfa');
|
||||
const options = await AuthMfa.generateWebAuthnRegistrationOptions(this.apiContext, user.id);
|
||||
const options = await AuthMfa.generateWebAuthnRegistrationOptions(this.apiContext, user.id, origin);
|
||||
return this.toWebAuthnChallengeResponse(options);
|
||||
}
|
||||
|
||||
@@ -217,8 +217,8 @@ export class UserAuthRequestService {
|
||||
return AuthMfa.getAvailableMfaMethods(this.apiContext, user.id);
|
||||
}
|
||||
|
||||
async getSudoWebAuthnOptions(user: User): Promise<WebAuthnChallengeResponse> {
|
||||
const options = await AuthMfa.generateWebAuthnOptionsForSudo(this.apiContext, user.id);
|
||||
async getSudoWebAuthnOptions(user: User, origin: string | undefined): Promise<WebAuthnChallengeResponse> {
|
||||
const options = await AuthMfa.generateWebAuthnOptionsForSudo(this.apiContext, user.id, origin);
|
||||
return this.toWebAuthnChallengeResponse(options);
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"@sapphi-red/web-noise-suppressor": "catalog:",
|
||||
"@simplewebauthn/browser": "catalog:",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"altcha-lib": "catalog:",
|
||||
"animejs": "4.5.0",
|
||||
"bowser": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
|
||||
@@ -233,6 +233,15 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.noticeLink {
|
||||
align-self: flex-start;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--text-link);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.integrationFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
MediaExpiryStep,
|
||||
type PremiumMode,
|
||||
PremiumStep,
|
||||
PushRelayConsentStep,
|
||||
type RegistrationMode,
|
||||
RegistrationStep,
|
||||
type ServiceAvailability,
|
||||
@@ -459,6 +460,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
youtube: false,
|
||||
bluesky: false,
|
||||
});
|
||||
const [pushRelayConsentAccepted, setPushRelayConsentAccepted] = useState(false);
|
||||
const [premiumMode, setPremiumMode] = useState<PremiumMode>('mirror');
|
||||
const [assets, setAssets] = useState<ReadonlyArray<BrandingAssetState>>(() =>
|
||||
BRANDING_ASSET_KINDS.map((kind) => ({kind, url: null, preview: null})),
|
||||
@@ -549,6 +551,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
clearStepNavigationLock();
|
||||
setIntegrationDraft({...DEFAULT_INTEGRATION_DRAFT});
|
||||
setMediaExpiryDraft({...DEFAULT_MEDIA_EXPIRY_DRAFT});
|
||||
setPushRelayConsentAccepted(false);
|
||||
setSmtpTesting(false);
|
||||
setSmtpTestResult(null);
|
||||
try {
|
||||
@@ -569,6 +572,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
setSingleCommunityEnabled(next.policy.single_community_enabled);
|
||||
setDirectMessagesDisabled(next.policy.direct_messages_disabled);
|
||||
setPremiumMode(next.policy.premium_mode);
|
||||
setPushRelayConsentAccepted(next.push_service_delivery.relay_consent_accepted);
|
||||
setServiceSelection({
|
||||
gif: next.policy.services_resolved.gif_enabled,
|
||||
youtube: next.policy.services_resolved.youtube_enabled,
|
||||
@@ -677,6 +681,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
if (step === 'branding') return !productNameError;
|
||||
if (step === 'community') return !singleCommunityNameError;
|
||||
if (step === 'media_expiry') return isMediaExpiryStepValid(mediaExpiryDraft);
|
||||
if (step === 'push_relay_consent') return true;
|
||||
const integrationKind = wizardStepToIntegrationKind(step);
|
||||
if (integrationKind) return isIntegrationStepValid(integrationKind, integrationDraft);
|
||||
return true;
|
||||
@@ -766,6 +771,10 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
const nextConfig = await updateInstanceConfig({
|
||||
integrations: buildIntegrationsPatch(integrationDraft),
|
||||
media: buildMediaPatch(mediaExpiryDraft),
|
||||
push_service_delivery:
|
||||
config.push_service_delivery.relay_consent_accepted === pushRelayConsentAccepted
|
||||
? undefined
|
||||
: {relay_consent_accepted: pushRelayConsentAccepted},
|
||||
registration: {mode: registrationMode},
|
||||
app_public: {
|
||||
branding: {
|
||||
@@ -804,6 +813,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
singleCommunityEnabled,
|
||||
singleCommunityNameTrimmed,
|
||||
directMessagesDisabled,
|
||||
pushRelayConsentAccepted,
|
||||
premiumMode,
|
||||
serviceAvailability,
|
||||
serviceSelection,
|
||||
@@ -972,6 +982,14 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.integration-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'push_relay_consent' && (
|
||||
<PushRelayConsentStep
|
||||
accepted={pushRelayConsentAccepted}
|
||||
disabled={submitting}
|
||||
onChange={setPushRelayConsentAccepted}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.push-relay-consent-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'services' && (
|
||||
<ServicesStep
|
||||
available={serviceAvailability}
|
||||
@@ -996,6 +1014,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
singleCommunityEnabled={singleCommunityEnabled}
|
||||
directMessagesDisabled={directMessagesDisabled}
|
||||
attachmentExpiryEnabled={mediaExpiryDraft.enabled}
|
||||
pushRelayConsentAccepted={pushRelayConsentAccepted}
|
||||
premiumMode={premiumMode}
|
||||
submitError={submitError}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.finish-step"
|
||||
|
||||
@@ -17,6 +17,7 @@ export type WizardStep =
|
||||
| 'integration_captcha'
|
||||
| 'integration_email'
|
||||
| 'integration_bluesky'
|
||||
| 'push_relay_consent'
|
||||
| 'services'
|
||||
| 'premium'
|
||||
| 'finish';
|
||||
@@ -36,6 +37,7 @@ export const CONFIGURE_STEPS: ReadonlyArray<WizardStep> = [
|
||||
'integration_captcha',
|
||||
'integration_email',
|
||||
'integration_bluesky',
|
||||
'push_relay_consent',
|
||||
'services',
|
||||
'premium',
|
||||
'finish',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerFiel
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {Switch} from '@app/features/ui/components/form/FormSwitch';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {RadioGroup, type RadioOption} from '@app/features/ui/radio_group/RadioGroup';
|
||||
import {ThemeSelector} from '@app/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent';
|
||||
import {LanguageSelector} from '@app/features/user/components/modals/tabs/LanguageTab';
|
||||
@@ -27,6 +28,8 @@ import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
const PUSH_RELAY_NOTICE_URL = 'https://fluxer.com/push-relay';
|
||||
|
||||
export type RegistrationMode = 'open' | 'approval' | 'closed';
|
||||
export type PremiumMode = 'mirror' | 'everyone';
|
||||
|
||||
@@ -263,6 +266,29 @@ const MEDIA_RENEW_WINDOW_LABEL_DESCRIPTOR = msg({
|
||||
comment: 'Label for attachment decay renewal window.',
|
||||
});
|
||||
|
||||
const PUSH_RELAY_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Mobile push notifications',
|
||||
comment: 'Setup wizard push relay consent step title.',
|
||||
});
|
||||
const PUSH_RELAY_BODY_DESCRIPTOR = msg({
|
||||
message:
|
||||
"The official Fluxer mobile apps receive notifications through Fluxer's push relay, which hands them to Apple and Google. Self-hosted UnifiedPush and ntfy endpoints never reach the relay and need no agreement.",
|
||||
comment: 'Setup wizard push relay consent step body.',
|
||||
});
|
||||
const PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR = msg({
|
||||
message: 'Accept the push relay supplemental privacy notice',
|
||||
comment: 'Label for the push relay consent switch during setup.',
|
||||
});
|
||||
const PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Leaving this off keeps the relay unused and drops notifications to the official mobile apps. You can accept it later in the admin panel.',
|
||||
comment: 'Description for the push relay consent switch during setup.',
|
||||
});
|
||||
const PUSH_RELAY_NOTICE_LINK_DESCRIPTOR = msg({
|
||||
message: 'Read the supplemental privacy notice',
|
||||
comment: 'Link to the push relay supplemental privacy notice shown during setup.',
|
||||
});
|
||||
|
||||
const SERVICES_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Optional services',
|
||||
comment: 'Setup wizard optional services step title.',
|
||||
@@ -353,10 +379,22 @@ const SUMMARY_ATTACHMENT_EXPIRY_DESCRIPTOR = msg({
|
||||
message: 'Attachment expiration',
|
||||
comment: 'Summary row label for the attachment expiry choice in the setup wizard.',
|
||||
});
|
||||
const SUMMARY_PUSH_RELAY_DESCRIPTOR = msg({
|
||||
message: 'Push relay notice',
|
||||
comment: 'Summary row label for the push relay consent on the setup wizard finish step.',
|
||||
});
|
||||
const SUMMARY_PREMIUM_DESCRIPTOR = msg({
|
||||
message: 'Premium model',
|
||||
comment: 'Summary row label for the premium model in the setup wizard.',
|
||||
});
|
||||
const SUMMARY_ACCEPTED_DESCRIPTOR = msg({
|
||||
message: 'Accepted',
|
||||
comment: 'Summary value when the operator accepted the push relay notice.',
|
||||
});
|
||||
const SUMMARY_NOT_ACCEPTED_DESCRIPTOR = msg({
|
||||
message: 'Not accepted',
|
||||
comment: 'Summary value when the operator left the push relay notice unaccepted.',
|
||||
});
|
||||
const SUMMARY_ON_DESCRIPTOR = msg({
|
||||
message: 'Enabled',
|
||||
comment: 'Summary value when a setup option is enabled.',
|
||||
@@ -1531,6 +1569,40 @@ export const IntegrationStep = observer(
|
||||
},
|
||||
);
|
||||
|
||||
export const PushRelayConsentStep = observer(
|
||||
({accepted, disabled, onChange}: {accepted: boolean; disabled: boolean; onChange: (value: boolean) => void}) => {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-step">
|
||||
<StepHeader
|
||||
title={i18n._(PUSH_RELAY_TITLE_DESCRIPTOR)}
|
||||
body={i18n._(PUSH_RELAY_BODY_DESCRIPTOR)}
|
||||
data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.step-header"
|
||||
/>
|
||||
<Switch
|
||||
label={i18n._(PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR)}
|
||||
description={i18n._(PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR)}
|
||||
value={accepted}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-switch"
|
||||
/>
|
||||
<FocusRing data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.focus-ring">
|
||||
<a
|
||||
className={styles.noticeLink}
|
||||
href={PUSH_RELAY_NOTICE_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-flx="app.self-hosted-setup-wizard-gate.push-relay-notice-link"
|
||||
>
|
||||
{i18n._(PUSH_RELAY_NOTICE_LINK_DESCRIPTOR)}
|
||||
</a>
|
||||
</FocusRing>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ServicesStep = observer(
|
||||
({
|
||||
available,
|
||||
@@ -1650,6 +1722,7 @@ export const FinishStep = observer(
|
||||
singleCommunityEnabled,
|
||||
directMessagesDisabled,
|
||||
attachmentExpiryEnabled,
|
||||
pushRelayConsentAccepted,
|
||||
premiumMode,
|
||||
submitError,
|
||||
}: {
|
||||
@@ -1658,6 +1731,7 @@ export const FinishStep = observer(
|
||||
singleCommunityEnabled: boolean;
|
||||
directMessagesDisabled: boolean;
|
||||
attachmentExpiryEnabled: boolean;
|
||||
pushRelayConsentAccepted: boolean;
|
||||
premiumMode: PremiumMode;
|
||||
submitError: string | null;
|
||||
}) => {
|
||||
@@ -1705,10 +1779,17 @@ export const FinishStep = observer(
|
||||
value={attachmentExpiryEnabled ? onLabel : offLabel}
|
||||
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--5"
|
||||
/>
|
||||
<SummaryRow
|
||||
label={i18n._(SUMMARY_PUSH_RELAY_DESCRIPTOR)}
|
||||
value={
|
||||
pushRelayConsentAccepted ? i18n._(SUMMARY_ACCEPTED_DESCRIPTOR) : i18n._(SUMMARY_NOT_ACCEPTED_DESCRIPTOR)
|
||||
}
|
||||
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6"
|
||||
/>
|
||||
<SummaryRow
|
||||
label={i18n._(SUMMARY_PREMIUM_DESCRIPTOR)}
|
||||
value={premiumLabel}
|
||||
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6"
|
||||
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--7"
|
||||
/>
|
||||
</div>
|
||||
{submitError && (
|
||||
|
||||
@@ -21,6 +21,11 @@ export const Endpoints = {
|
||||
AUTH_HANDOFF_INFO: (code: string) => `/auth/handoff/${code}/info`,
|
||||
AUTH_HANDOFF_STATUS: (code: string) => `/auth/handoff/${code}/status`,
|
||||
AUTH_HANDOFF_CANCEL: (code: string) => `/auth/handoff/${code}`,
|
||||
AUTH_PASSKEY_BRIDGE: '/auth/passkey-bridge',
|
||||
AUTH_PASSKEY_BRIDGE_OPTIONS: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/options`,
|
||||
AUTH_PASSKEY_BRIDGE_COMPLETE: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/complete`,
|
||||
AUTH_PASSKEY_BRIDGE_CANCEL: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/cancel`,
|
||||
AUTH_PASSKEY_BRIDGE_REDEEM: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/redeem`,
|
||||
AUTH_FORGOT_PASSWORD: '/auth/forgot',
|
||||
AUTH_RESET_PASSWORD: '/auth/reset',
|
||||
AUTH_VALIDATE_RESET_PASSWORD_TOKEN: (token: string) => `/auth/reset/${encodeURIComponent(token)}`,
|
||||
@@ -173,6 +178,10 @@ export const Endpoints = {
|
||||
USER_MFA_WEBAUTHN_REGISTRATION_OPTIONS: '/users/@me/mfa/webauthn/credentials/registration-options',
|
||||
USER_MFA_WEBAUTHN_CREDENTIAL: (credentialId: string) => `/users/@me/mfa/webauthn/credentials/${credentialId}`,
|
||||
USER_MFA_WEBAUTHN_TWO_FACTOR: '/users/@me/mfa/webauthn/two-factor',
|
||||
USER_MFA_WEBAUTHN_MIGRATION: '/users/@me/mfa/webauthn/migration',
|
||||
USER_MFA_WEBAUTHN_MIGRATION_REGISTRATION_OPTIONS: '/users/@me/mfa/webauthn/migration/registration-options',
|
||||
USER_PASSKEY_BRIDGE: '/users/@me/passkey-bridge',
|
||||
USER_PASSKEY_BRIDGE_REDEEM: (ceremonyId: string) => `/users/@me/passkey-bridge/${ceremonyId}/redeem`,
|
||||
USER_PHONE_SEND_VERIFICATION: '/users/@me/phone/send-verification',
|
||||
USER_PHONE_INBOUND_CHALLENGE: '/users/@me/phone/inbound-challenge',
|
||||
USER_PHONE_VERIFY: '/users/@me/phone/verify',
|
||||
|
||||
@@ -60,7 +60,7 @@ function environment(
|
||||
installKind: core.DomainMigrationInstallKind,
|
||||
overrides: Partial<core.DomainMigrationEnvironment> = {},
|
||||
): core.DomainMigrationEnvironment {
|
||||
return {installKind, electron: false, electronMigrationVersion: null, ...overrides};
|
||||
return {installKind, electron: false, electronMigrationVersion: null, electronPasskeyRpIds: [], ...overrides};
|
||||
}
|
||||
|
||||
function gateInput(overrides: Partial<core.DomainMigrationGateInput> = {}): core.DomainMigrationGateInput {
|
||||
@@ -70,7 +70,6 @@ function gateInput(overrides: Partial<core.DomainMigrationGateInput> = {}): core
|
||||
discovery: ENABLED_DISCOVERY,
|
||||
marker: null,
|
||||
now: NOW,
|
||||
relatedOriginsSupported: true,
|
||||
voiceActive: false,
|
||||
oneShotRoute: false,
|
||||
...overrides,
|
||||
@@ -255,7 +254,13 @@ describe('migration gate', () => {
|
||||
expect(core.shouldStartDomainMigration(gateInput())).toBe(true);
|
||||
expect(
|
||||
core.shouldStartDomainMigration(
|
||||
gateInput({environment: environment('none', {electron: true, electronMigrationVersion: 1})}),
|
||||
gateInput({
|
||||
environment: environment('none', {
|
||||
electron: true,
|
||||
electronMigrationVersion: 1,
|
||||
electronPasskeyRpIds: ['fluxer.app', 'fluxer.com'],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -272,7 +277,16 @@ describe('migration gate', () => {
|
||||
['Firefox web app', {environment: environment('firefox')}],
|
||||
['other web app', {environment: environment('other')}],
|
||||
['old desktop', {environment: environment('none', {electron: true})}],
|
||||
['no related origins', {relatedOriginsSupported: false}],
|
||||
[
|
||||
'desktop that cannot create fluxer.com passkeys',
|
||||
{
|
||||
environment: environment('none', {
|
||||
electron: true,
|
||||
electronMigrationVersion: 1,
|
||||
electronPasskeyRpIds: ['fluxer.app'],
|
||||
}),
|
||||
},
|
||||
],
|
||||
['in a voice call', {voiceActive: true}],
|
||||
['on a one-shot token route', {oneShotRoute: true}],
|
||||
])('blocks when %s', (_label, overrides) => {
|
||||
|
||||
@@ -27,10 +27,6 @@ const DISPLAY_MODES: ReadonlyArray<DomainMigrationDisplayMode> = [
|
||||
'minimal-ui',
|
||||
];
|
||||
|
||||
interface PublicKeyCredentialWithCapabilities {
|
||||
getClientCapabilities?: () => Promise<Record<string, boolean | undefined>>;
|
||||
}
|
||||
|
||||
export function readDomainMigrationDiscovery(): DomainMigrationDiscoveryResponse | null {
|
||||
return window.__FLUXER_BOOTSTRAP__?.instance.domain_migration ?? null;
|
||||
}
|
||||
@@ -68,20 +64,16 @@ export function readDomainMigrationEnvironment(): DomainMigrationEnvironment {
|
||||
installKind: detectDomainMigrationInstallKind(),
|
||||
electron: isElectronEnvironment(),
|
||||
electronMigrationVersion: window.electron?.domainMigration?.version ?? null,
|
||||
electronPasskeyRpIds: window.electron?.passkeyRpIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function browserSupportsRelatedOrigins(): Promise<boolean> {
|
||||
if (typeof PublicKeyCredential === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const credential = PublicKeyCredential as unknown as PublicKeyCredentialWithCapabilities;
|
||||
if (typeof credential.getClientCapabilities !== 'function') {
|
||||
return false;
|
||||
export async function desktopPasskeysSupported(): Promise<boolean> {
|
||||
if (!isElectronEnvironment()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const capabilities = await credential.getClientCapabilities();
|
||||
return capabilities.relatedOrigins === true;
|
||||
return (await window.electron?.passkeyIsSupported?.()) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfig';
|
||||
import type {StoredAccount} from '@app/features/auth/state/AccountStorage';
|
||||
import {isIOSMobileOrTabletUserAgent} from '@app/features/platform/notifications/NotificationAlertOptions';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
|
||||
@@ -256,6 +257,7 @@ export interface DomainMigrationEnvironment {
|
||||
installKind: DomainMigrationInstallKind;
|
||||
electron: boolean;
|
||||
electronMigrationVersion: number | null;
|
||||
electronPasskeyRpIds: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export function environmentAllowsDomainMigration(environment: DomainMigrationEnvironment): boolean {
|
||||
@@ -263,7 +265,11 @@ export function environmentAllowsDomainMigration(environment: DomainMigrationEnv
|
||||
return false;
|
||||
}
|
||||
if (environment.electron) {
|
||||
return environment.electronMigrationVersion !== null && environment.electronMigrationVersion >= 1;
|
||||
return (
|
||||
environment.electronMigrationVersion !== null &&
|
||||
environment.electronMigrationVersion >= 1 &&
|
||||
environment.electronPasskeyRpIds.includes(PASSKEY_MIGRATION_RP_ID)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -284,7 +290,6 @@ export interface DomainMigrationGateInput {
|
||||
discovery: DomainMigrationDiscoveryResponse | null;
|
||||
marker: DomainMigrationMarker | null;
|
||||
now: number;
|
||||
relatedOriginsSupported: boolean;
|
||||
voiceActive: boolean;
|
||||
oneShotRoute: boolean;
|
||||
}
|
||||
@@ -295,7 +300,6 @@ export function shouldStartDomainMigration(input: DomainMigrationGateInput): boo
|
||||
input.discovery?.enabled === true &&
|
||||
markerAllowsDomainMigration(input.marker, input.now) &&
|
||||
environmentAllowsDomainMigration(input.environment) &&
|
||||
input.relatedOriginsSupported &&
|
||||
!input.voiceActive &&
|
||||
!input.oneShotRoute
|
||||
);
|
||||
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
randomBase64Url,
|
||||
sha256Hex,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCrypto';
|
||||
import {resolvePasskeyBridgeOpenerOrigin} from '@app/features/auth/utils/PasskeyBridgeProtocol';
|
||||
import type {SoundType} from '@app/features/notification/utils/SoundUtils';
|
||||
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -593,7 +592,7 @@ export async function runDomainMigrationPreMount(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
const side = resolveDomainMigrationSide(window.location.origin);
|
||||
if (side === null || resolvePasskeyBridgeOpenerOrigin(window.location.origin, window.location.pathname) !== null) {
|
||||
if (side === null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {
|
||||
browserSupportsRelatedOrigins,
|
||||
desktopPasskeysSupported,
|
||||
readDomainMigrationDiscovery,
|
||||
readDomainMigrationEnvironment,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
||||
@@ -50,28 +50,24 @@ function isOneShotRoute(pathname: string): boolean {
|
||||
return ONE_SHOT_ROUTE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||||
}
|
||||
|
||||
function readGateInput(assignmentEnabled: boolean, relatedOriginsSupported: boolean): DomainMigrationGateInput {
|
||||
function readGateInput(assignmentEnabled: boolean): DomainMigrationGateInput {
|
||||
return {
|
||||
environment: readDomainMigrationEnvironment(),
|
||||
assignmentEnabled,
|
||||
discovery: readDomainMigrationDiscovery(),
|
||||
marker: readDomainMigrationMarker(getProtectedLocalStorage()),
|
||||
now: Date.now(),
|
||||
relatedOriginsSupported,
|
||||
voiceActive: isVoiceActive(),
|
||||
oneShotRoute: isOneShotRoute(window.location.pathname),
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluateSource(side: DomainMigrationSide, assignmentEnabled: boolean): Promise<void> {
|
||||
if (navigating || !shouldStartDomainMigration(readGateInput(assignmentEnabled, true))) {
|
||||
if (navigating || !shouldStartDomainMigration(readGateInput(assignmentEnabled))) {
|
||||
return;
|
||||
}
|
||||
const relatedOriginsSupported = await browserSupportsRelatedOrigins();
|
||||
if (
|
||||
navigating ||
|
||||
!shouldStartDomainMigration(readGateInput(DomainMigrationRollout.enabled, relatedOriginsSupported))
|
||||
) {
|
||||
const passkeysSupported = await desktopPasskeysSupported();
|
||||
if (navigating || !passkeysSupported || !shouldStartDomainMigration(readGateInput(DomainMigrationRollout.enabled))) {
|
||||
return;
|
||||
}
|
||||
navigating = true;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {solveChallengeWorkers} from 'altcha-lib';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
|
||||
export type AltchaChallenge = Challenge;
|
||||
|
||||
const MAX_SOLVER_WORKERS = 8;
|
||||
const SOLVE_TIMEOUT_MS = 120_000;
|
||||
|
||||
function createSolverWorker(): Worker {
|
||||
return new Worker(
|
||||
new URL(/* webpackChunkName: "altcha-solver.worker" */ './AltchaSolverWorker.ts', import.meta.url),
|
||||
{
|
||||
type: 'module',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function readAltchaChallenge(body: unknown): AltchaChallenge | null {
|
||||
if (typeof body !== 'object' || body === null) return null;
|
||||
const {captcha_provider: provider, altcha_challenge: challenge} = body as Record<string, unknown>;
|
||||
if (provider !== 'altcha' || typeof challenge !== 'object' || challenge === null) return null;
|
||||
const {parameters, signature} = challenge as Record<string, unknown>;
|
||||
if (typeof parameters !== 'object' || parameters === null || typeof signature !== 'string') return null;
|
||||
return challenge as AltchaChallenge;
|
||||
}
|
||||
|
||||
export async function solveAltchaChallenge(
|
||||
challenge: AltchaChallenge,
|
||||
controller: AbortController,
|
||||
): Promise<string | null> {
|
||||
const solution = await solveChallengeWorkers({
|
||||
challenge,
|
||||
concurrency: Math.min(MAX_SOLVER_WORKERS, navigator.hardwareConcurrency || 2),
|
||||
controller,
|
||||
createWorker: createSolverWorker,
|
||||
timeout: SOLVE_TIMEOUT_MS,
|
||||
});
|
||||
if (!solution) return null;
|
||||
return btoa(
|
||||
JSON.stringify({challenge: {parameters: challenge.parameters, signature: challenge.signature}, solution}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {deriveKey} from 'altcha-lib/algorithms/web/pbkdf2';
|
||||
import {handler} from 'altcha-lib/workers/shared';
|
||||
|
||||
handler({deriveKey});
|
||||
@@ -0,0 +1,16 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type AltchaChallenge, solveAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
|
||||
import styles from '@app/features/auth/components/AltchaVerification.module.css';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
const logger = new Logger('AltchaVerification');
|
||||
|
||||
interface AltchaVerificationProps {
|
||||
challenge: AltchaChallenge;
|
||||
onVerify: (token: string) => void;
|
||||
}
|
||||
|
||||
export function AltchaVerification({challenge, onVerify}: AltchaVerificationProps) {
|
||||
const onVerifyRef = useRef(onVerify);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
onVerifyRef.current = onVerify;
|
||||
}, [onVerify]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setFailed(false);
|
||||
solveAltchaChallenge(challenge, controller).then(
|
||||
(token) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (token) {
|
||||
onVerifyRef.current(token);
|
||||
} else {
|
||||
setFailed(true);
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
logger.error('ALTCHA solve failed:', error);
|
||||
setFailed(true);
|
||||
},
|
||||
);
|
||||
return () => controller.abort();
|
||||
}, [challenge, attempt]);
|
||||
const handleRetry = useCallback(() => setAttempt((value) => value + 1), []);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.altcha-verification.failed">
|
||||
<p className={styles.text} data-flx="auth.altcha-verification.failed-text">
|
||||
<Trans>Your browser couldn't finish the check.</Trans>
|
||||
</p>
|
||||
<Button small variant="secondary" onClick={handleRetry} data-flx="auth.altcha-verification.retry-button">
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.container} role="status" aria-live="polite" data-flx="auth.altcha-verification.solving">
|
||||
<Spinner data-flx="auth.altcha-verification.spinner" />
|
||||
<p className={styles.text} data-flx="auth.altcha-verification.solving-text">
|
||||
<Trans>Checking your browser. This takes a few seconds.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type AltchaChallenge, readAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
|
||||
import {CaptchaModal, type CaptchaType} from '@app/features/auth/components/modals/CaptchaModal';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
|
||||
@@ -69,7 +70,7 @@ class CaptchaInterceptorState {
|
||||
return code === 'CAPTCHA_REQUIRED' || code === 'INVALID_CAPTCHA';
|
||||
}
|
||||
|
||||
private showCaptchaModal(): Promise<CaptchaResult> {
|
||||
private showCaptchaModal(altchaChallenge: AltchaChallenge | null): Promise<CaptchaResult> {
|
||||
if (this.pendingPromise) {
|
||||
this.pendingPromise.reject(new Error('Captcha cancelled'));
|
||||
this.pendingPromise = null;
|
||||
@@ -95,6 +96,7 @@ class CaptchaInterceptorState {
|
||||
};
|
||||
const CaptchaModalWrapper = observer(() => (
|
||||
<CaptchaModal
|
||||
altchaChallenge={altchaChallenge}
|
||||
onVerify={handleVerify}
|
||||
onCancel={handleCancel}
|
||||
error={this.state.error}
|
||||
@@ -119,7 +121,7 @@ class CaptchaInterceptorState {
|
||||
const errorMessage = replyMessage(reply.body) || i18n._(CAPTCHA_VERIFICATION_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR);
|
||||
this.state.setError(errorMessage);
|
||||
this.state.setIsVerifying(false);
|
||||
const promise = this.showCaptchaModal()
|
||||
const promise = this.showCaptchaModal(readAltchaChallenge(reply.body))
|
||||
.then((captchaResult) => {
|
||||
this.state.setError(null);
|
||||
this.state.setIsVerifying(false);
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isPasskeyBridgeAvailable} from '@app/features/auth/utils/PasskeyBridge';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {WarningAlert} from '@app/features/ui/warning_alert/WarningAlert';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {PasswordIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
|
||||
const USE_A_PASSWORD_MANAGER_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Use a password manager passkey',
|
||||
comment:
|
||||
'Button that runs the passkey prompt in a small pop-up window on the old web address, so password manager extensions can find passkeys saved there.',
|
||||
});
|
||||
const PASSWORD_MANAGER_PASSKEY_SUGGESTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Password managers may not offer your passkey on this web address. Try it in a pop-up window on the old address instead.',
|
||||
comment:
|
||||
'Shown after a passkey prompt fails, above the button that retries the passkey prompt in a pop-up window on the old web address.',
|
||||
});
|
||||
|
||||
interface PasswordManagerPasskeyActionProps {
|
||||
suggested: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
export function PasswordManagerPasskeyAction({suggested, disabled, onClick}: PasswordManagerPasskeyActionProps) {
|
||||
const {i18n} = useLingui();
|
||||
if (!isPasskeyBridgeAvailable()) {
|
||||
return null;
|
||||
}
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
fitContainer
|
||||
variant={suggested ? 'primary' : 'secondary'}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
leftIcon={<PasswordIcon size={16} data-flx="auth.password-manager-passkey-action.icon" />}
|
||||
data-flx="auth.password-manager-passkey-action.button"
|
||||
>
|
||||
{i18n._(USE_A_PASSWORD_MANAGER_PASSKEY_DESCRIPTOR)}
|
||||
</Button>
|
||||
);
|
||||
if (!suggested) {
|
||||
return button;
|
||||
}
|
||||
return (
|
||||
<WarningAlert actions={button} data-flx="auth.password-manager-passkey-action.suggestion">
|
||||
{i18n._(PASSWORD_MANAGER_PASSKEY_SUGGESTION_DESCRIPTOR)}
|
||||
</WarningAlert>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import type {AltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
|
||||
import {AltchaVerification} from '@app/features/auth/components/AltchaVerification';
|
||||
import styles from '@app/features/auth/components/modals/CaptchaModal.module.css';
|
||||
import {TurnstileWidget} from '@app/features/auth/components/TurnstileWidget';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -18,7 +20,7 @@ const VERIFY_YOU_RE_HUMAN_DESCRIPTOR = msg({
|
||||
});
|
||||
const logger = new Logger('CaptchaModal');
|
||||
|
||||
export type CaptchaType = 'turnstile' | 'hcaptcha';
|
||||
export type CaptchaType = 'turnstile' | 'hcaptcha' | 'altcha';
|
||||
|
||||
interface HCaptchaComponentProps {
|
||||
sitekey: string;
|
||||
@@ -35,16 +37,26 @@ interface CaptchaModalProps {
|
||||
onVerify: (token: string, captchaType: CaptchaType) => void;
|
||||
onCancel?: () => void;
|
||||
preferredType?: CaptchaType;
|
||||
altchaChallenge?: AltchaChallenge | null;
|
||||
error?: string | null;
|
||||
isVerifying?: boolean;
|
||||
closeOnVerify?: boolean;
|
||||
}
|
||||
|
||||
export const CaptchaModal = observer(
|
||||
({onVerify, onCancel, preferredType, error, isVerifying, closeOnVerify = true}: CaptchaModalProps) => {
|
||||
({
|
||||
onVerify,
|
||||
onCancel,
|
||||
preferredType,
|
||||
altchaChallenge,
|
||||
error,
|
||||
isVerifying,
|
||||
closeOnVerify = true,
|
||||
}: CaptchaModalProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const hcaptchaRef = useRef<HCaptcha>(null);
|
||||
const [captchaType, setCaptchaType] = useState<CaptchaType>(() => {
|
||||
if (altchaChallenge) return 'altcha';
|
||||
if (preferredType) return preferredType;
|
||||
if (RuntimeConfig.captchaProvider === 'turnstile' && RuntimeConfig.turnstileSiteKey) {
|
||||
return 'turnstile';
|
||||
@@ -123,7 +135,13 @@ export const CaptchaModal = observer(
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.captchaContainer} data-flx="auth.captcha-modal.captcha-container">
|
||||
{captchaType === 'turnstile' ? (
|
||||
{captchaType === 'altcha' && altchaChallenge ? (
|
||||
<AltchaVerification
|
||||
challenge={altchaChallenge}
|
||||
onVerify={handleVerify}
|
||||
data-flx="auth.captcha-modal.altcha-verification"
|
||||
/>
|
||||
) : captchaType === 'turnstile' ? (
|
||||
<TurnstileWidget
|
||||
sitekey={RuntimeConfig.turnstileSiteKey ?? ''}
|
||||
onVerify={handleVerify}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import {describePasskeyBridgeFailure, shouldSuggestPasskeyBridge} from '@app/features/auth/utils/PasskeyBridge';
|
||||
import {
|
||||
PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR,
|
||||
PasskeyDomainUnsupportedError,
|
||||
} from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -16,8 +10,6 @@ import * as FormUtils from '@app/lib/forms';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
|
||||
const NAME_PASSKEY_FORM_DESCRIPTOR = msg({
|
||||
@@ -41,58 +33,16 @@ interface FormInputs {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PasskeyNameModalProps {
|
||||
onSubmit: (name: string) => void | Promise<void>;
|
||||
onSubmitWithPasswordManager?: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager}: PasskeyNameModalProps) => {
|
||||
export const PasskeyNameModal = observer(({onSubmit}: {onSubmit: (name: string) => void | Promise<void>}) => {
|
||||
const {i18n} = useLingui();
|
||||
const form = useForm<FormInputs>();
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const [passkeyBridgeSubmitting, setPasskeyBridgeSubmitting] = useState(false);
|
||||
const handlePasswordManagerSubmit = (
|
||||
event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (!onSubmitWithPasswordManager || passkeyBridgeSubmitting || form.formState.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
if (event.currentTarget.form?.reportValidity() === false) {
|
||||
return;
|
||||
}
|
||||
const submission = onSubmitWithPasswordManager(form.getValues('name').trim());
|
||||
form.clearErrors('name');
|
||||
setPasskeyBridgeSubmitting(true);
|
||||
submission
|
||||
.then(() => {
|
||||
ModalCommands.pop();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof HttpError) {
|
||||
FormUtils.handleError(i18n, form, error, 'name');
|
||||
return;
|
||||
}
|
||||
const descriptor = describePasskeyBridgeFailure(error);
|
||||
if (descriptor) {
|
||||
form.setError('name', {type: 'server', message: i18n._(descriptor)});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setPasskeyBridgeSubmitting(false);
|
||||
});
|
||||
};
|
||||
const handleSubmit = async (data: FormInputs) => {
|
||||
try {
|
||||
await onSubmit(data.name.trim());
|
||||
ModalCommands.pop();
|
||||
} catch (error) {
|
||||
if (onSubmitWithPasswordManager && shouldSuggestPasskeyBridge(error)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
}
|
||||
if (error instanceof HttpError) {
|
||||
FormUtils.handleError(i18n, form, error, 'name');
|
||||
} else if (error instanceof PasskeyDomainUnsupportedError) {
|
||||
form.setError('name', {type: 'server', message: i18n._(PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR)});
|
||||
} else {
|
||||
form.setError('name', {type: 'server', message: FormUtils.extractErrorMessage(i18n, error)});
|
||||
}
|
||||
@@ -123,14 +73,6 @@ export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager
|
||||
required={true}
|
||||
type="text"
|
||||
/>
|
||||
{onSubmitWithPasswordManager && (
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passkeyBridgeSuggested}
|
||||
disabled={form.formState.isSubmitting || passkeyBridgeSubmitting}
|
||||
onClick={handlePasswordManagerSubmit}
|
||||
data-flx="auth.passkey-name-modal.password-manager-passkey-action"
|
||||
/>
|
||||
)}
|
||||
</Modal.ContentLayout>
|
||||
</Modal.Content>
|
||||
<Modal.Footer data-flx="auth.passkey-name-modal.modal-footer">
|
||||
@@ -140,7 +82,6 @@ export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager
|
||||
<Button
|
||||
type="submit"
|
||||
submitting={form.formState.isSubmitting}
|
||||
disabled={passkeyBridgeSubmitting}
|
||||
data-flx="auth.passkey-name-modal.button.submit"
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
|
||||
@@ -51,3 +51,9 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.passkeyHint {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
@@ -3,25 +3,37 @@
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import styles from '@app/features/auth/components/modals/SudoVerificationModal.module.css';
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import SudoPrompt, {SudoVerificationMethod} from '@app/features/auth/state/SudoPrompt';
|
||||
import {
|
||||
describePasskeyBridgeFailure,
|
||||
runPasskeyViaBridge,
|
||||
shouldSuggestPasskeyBridge,
|
||||
} from '@app/features/auth/utils/PasskeyBridge';
|
||||
isPasskeyCeremonyDismissed,
|
||||
PasskeyBridgeSudoLink,
|
||||
runPasskeyBridgeNativeSudo,
|
||||
} from '@app/features/auth/passkey_migration/PasskeyLegacyCeremony';
|
||||
import PasskeyMigration from '@app/features/auth/passkey_migration/PasskeyMigration';
|
||||
import {isPasskeyMigrationOrigin} from '@app/features/auth/passkey_migration/PasskeyMigrationOrigin';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Sudo from '@app/features/auth/state/AuthSudo';
|
||||
import SudoPrompt, {SUDO_MODAL_KEY, SudoVerificationMethod} from '@app/features/auth/state/SudoPrompt';
|
||||
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {PASSWORD_DESCRIPTOR, VERIFY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {
|
||||
COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR,
|
||||
PASSWORD_DESCRIPTOR,
|
||||
VERIFY_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import buttonStyles from '@app/features/ui/button/Button.module.css';
|
||||
import {Form} from '@app/features/ui/components/form/Form';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import WebAuthnCredentials from '@app/features/user/state/WebAuthnCredentials';
|
||||
import * as FormUtils from '@app/lib/forms';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import type {PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
@@ -33,10 +45,6 @@ const PASSKEYS_REQUIRE_A_SIGNED_MACOS_BUNDLE_WITH_A_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Sudo (re-auth) modal body shown on unsigned macOS desktop bundles where passkeys cannot work. Direct the user to install the signed client.',
|
||||
});
|
||||
const COULDN_T_VERIFY_WITH_PASSKEY_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
|
||||
message: "Couldn't verify with passkey. Try again.",
|
||||
comment: 'Sudo (re-auth) modal toast error shown when passkey verification fails. Keep plain.',
|
||||
});
|
||||
const ENTER_YOUR_PASSWORD_DESCRIPTOR = msg({
|
||||
message: 'Enter your password.',
|
||||
comment: 'Body text in the authentication sudo verification modal. Keep the tone plain and specific.',
|
||||
@@ -72,6 +80,11 @@ const BACKUP_CODE_DESCRIPTOR = msg({
|
||||
'Label and placeholder for the code field in the authentication sudo verification modal when the only code the account can use is a backup code.',
|
||||
});
|
||||
const VERIFICATION_FAILED_DESCRIPTOR = msg({message: 'Verification failed'});
|
||||
const FINISH_IN_THE_NEW_TAB_DESCRIPTOR = msg({
|
||||
message: 'Finish in the new tab. If you closed it, press Continue with passkey again.',
|
||||
comment:
|
||||
'Sudo (re-auth) modal hint shown after the passkey button opened a new tab to confirm the passkey. "Continue with passkey" is the button label. Keep plain.',
|
||||
});
|
||||
const logger = new Logger('SudoVerificationModal');
|
||||
|
||||
interface FormInputs {
|
||||
@@ -83,14 +96,24 @@ const isMacAppIdentifierError = (error: unknown): boolean => {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
return message.toLowerCase().includes('application identifier');
|
||||
};
|
||||
const holdsPasskeyFor = (matches: (rpId: string) => boolean): boolean =>
|
||||
WebAuthnCredentials.credentials.some((credential) => matches(credential.rp_id));
|
||||
const holdsMigratedPasskey = (): boolean => holdsPasskeyFor((rpId) => rpId === PASSKEY_MIGRATION_RP_ID);
|
||||
const holdsLegacyPasskey = (): boolean => holdsPasskeyFor((rpId) => rpId !== PASSKEY_MIGRATION_RP_ID);
|
||||
const SudoVerificationModal: React.FC = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const {availableMethods, isVerifying, verificationFailed, rawError, lastUsedMfaMethod} = SudoPrompt;
|
||||
const form = useForm<FormInputs>({defaultValues: {password: '', totp: ''}});
|
||||
const [webAuthnInFlight, setWebAuthnInFlight] = useState(false);
|
||||
const [webAuthnError, setWebAuthnError] = useState<string | null>(null);
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const autoTriggeredRef = useRef(false);
|
||||
const [legacyLinkUrl, setLegacyLinkUrl] = useState<string | null>(null);
|
||||
const [legacyLinkActive, setLegacyLinkActive] = useState(false);
|
||||
const [legacyLinkFollowed, setLegacyLinkFollowed] = useState(false);
|
||||
const legacyLinkRef = useRef<PasskeyBridgeSudoLink | null>(null);
|
||||
const preferLegacyRef = useRef(false);
|
||||
const openRef = useRef(true);
|
||||
const userIdAtOpenRef = useRef(AccountManager.currentUserId);
|
||||
const showPasskey = availableMethods.webauthn;
|
||||
const showTotp = availableMethods.totp;
|
||||
const backupCodeOnly = !showTotp && availableMethods.backupCodes;
|
||||
@@ -113,12 +136,59 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
}
|
||||
setWebAuthnInFlight(false);
|
||||
}, [form, verificationFailed, rawError, i18n, i18n.locale, showPassword, showCode]);
|
||||
const finishLegacySudo = (sudoToken: string) => {
|
||||
if (!openRef.current || AccountManager.currentUserId !== userIdAtOpenRef.current) return;
|
||||
Sudo.setToken(sudoToken);
|
||||
PasskeyMigration.checkAfterSudo(SUDO_MODAL_KEY);
|
||||
SudoPrompt.submit({});
|
||||
};
|
||||
const startLegacyLink = () => {
|
||||
if (legacyLinkRef.current === null) {
|
||||
legacyLinkRef.current = new PasskeyBridgeSudoLink({
|
||||
onLink: (url) => {
|
||||
setLegacyLinkUrl(url);
|
||||
if (url === null) {
|
||||
setLegacyLinkFollowed(false);
|
||||
}
|
||||
},
|
||||
onCompleted: finishLegacySudo,
|
||||
onError: (error) => {
|
||||
logger.error('WebAuthn verification in a new tab failed', error);
|
||||
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR));
|
||||
},
|
||||
});
|
||||
}
|
||||
setLegacyLinkActive(true);
|
||||
void legacyLinkRef.current.start();
|
||||
};
|
||||
useEffect(() => {
|
||||
if (showPasskey && isPasskeyMigrationOrigin() && !Platform.isElectron && !holdsMigratedPasskey()) {
|
||||
startLegacyLink();
|
||||
}
|
||||
return () => {
|
||||
openRef.current = false;
|
||||
legacyLinkRef.current?.dispose();
|
||||
};
|
||||
}, []);
|
||||
const handleWebAuthn = async () => {
|
||||
if (webAuthnInFlight || isVerifying) return;
|
||||
setWebAuthnError(null);
|
||||
form.clearErrors();
|
||||
setWebAuthnInFlight(true);
|
||||
const migrationOrigin = isPasskeyMigrationOrigin();
|
||||
const runsLegacyNatively =
|
||||
migrationOrigin && Platform.isElectron && (preferLegacyRef.current || !holdsMigratedPasskey());
|
||||
try {
|
||||
if (runsLegacyNatively) {
|
||||
preferLegacyRef.current = false;
|
||||
const result = await runPasskeyBridgeNativeSudo();
|
||||
if (result.status === 'completed') {
|
||||
finishLegacySudo(result.sudo_token);
|
||||
return;
|
||||
}
|
||||
setWebAuthnInFlight(false);
|
||||
return;
|
||||
}
|
||||
await WebAuthnUtils.assertWebAuthnSupported();
|
||||
const optionsResponse = await http.post<{challenge: string}>(Endpoints.SUDO_WEBAUTHN_OPTIONS);
|
||||
const credential = await WebAuthnUtils.performAuthentication(optionsResponse.body);
|
||||
@@ -130,49 +200,22 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
} catch (err) {
|
||||
logger.error('WebAuthn verification failed', err);
|
||||
setWebAuthnInFlight(false);
|
||||
if (migrationOrigin && !runsLegacyNatively && isPasskeyCeremonyDismissed(err) && holdsLegacyPasskey()) {
|
||||
if (!Platform.isElectron) {
|
||||
startLegacyLink();
|
||||
return;
|
||||
}
|
||||
preferLegacyRef.current = true;
|
||||
}
|
||||
if (isMacAppIdentifierError(err)) {
|
||||
setWebAuthnError(i18n._(PASSKEYS_REQUIRE_A_SIGNED_MACOS_BUNDLE_WITH_A_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
if (shouldSuggestPasskeyBridge(err)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
return;
|
||||
}
|
||||
if (err instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
|
||||
setWebAuthnError(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_PLEASE_TRY_AGAIN_DESCRIPTOR));
|
||||
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR));
|
||||
}
|
||||
};
|
||||
const handlePasskeyBridge = () => {
|
||||
if (webAuthnInFlight || isVerifying) return;
|
||||
const options = http
|
||||
.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.SUDO_WEBAUTHN_OPTIONS)
|
||||
.then((response) => response.body);
|
||||
const credential = runPasskeyViaBridge('authenticate', options);
|
||||
setWebAuthnError(null);
|
||||
form.clearErrors();
|
||||
setWebAuthnInFlight(true);
|
||||
Promise.all([options, credential])
|
||||
.then(([resolvedOptions, resolvedCredential]) => {
|
||||
SudoPrompt.submit({
|
||||
mfa_method: SudoVerificationMethod.WEBAUTHN,
|
||||
webauthn_challenge: resolvedOptions.challenge,
|
||||
webauthn_response: resolvedCredential,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.error('WebAuthn verification in the pop-up window failed', err);
|
||||
setWebAuthnInFlight(false);
|
||||
const descriptor = describePasskeyBridgeFailure(err);
|
||||
if (descriptor) {
|
||||
setWebAuthnError(i18n._(descriptor));
|
||||
}
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (autoTriggeredRef.current) return;
|
||||
if (autoTriggeredRef.current || legacyLinkRef.current !== null) return;
|
||||
if (!showPasskey || showPassword || showCode) return;
|
||||
if (lastUsedMfaMethod && lastUsedMfaMethod !== 'webauthn') return;
|
||||
autoTriggeredRef.current = true;
|
||||
@@ -263,7 +306,39 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
|
||||
{showPasskey && (
|
||||
<>
|
||||
{webAuthnInFlight ? (
|
||||
{legacyLinkActive && legacyLinkUrl !== null ? (
|
||||
<FocusRing offset={-2} data-flx="auth.sudo-verification-modal.focus-ring.legacy-link">
|
||||
<a
|
||||
href={legacyLinkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
setWebAuthnError(null);
|
||||
setLegacyLinkFollowed(true);
|
||||
}}
|
||||
className={clsx(
|
||||
buttonStyles.button,
|
||||
buttonStyles[showCode || showPassword ? 'secondary' : 'primary'],
|
||||
buttonStyles.fitContainer,
|
||||
)}
|
||||
data-flx="auth.sudo-verification-modal.link.web-authn"
|
||||
>
|
||||
<Trans>Continue with passkey</Trans>
|
||||
</a>
|
||||
</FocusRing>
|
||||
) : legacyLinkActive ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={startLegacyLink}
|
||||
submitting={webAuthnError === null}
|
||||
disabled={isVerifying}
|
||||
fitContainer
|
||||
variant={showCode || showPassword ? 'secondary' : 'primary'}
|
||||
data-flx="auth.sudo-verification-modal.button.web-authn-starting"
|
||||
>
|
||||
<Trans>Continue with passkey</Trans>
|
||||
</Button>
|
||||
) : webAuthnInFlight ? (
|
||||
<div
|
||||
className={styles.passkeyVerifying}
|
||||
role="status"
|
||||
@@ -287,13 +362,10 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
<Trans>Continue with passkey</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{!webAuthnInFlight && (
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passkeyBridgeSuggested}
|
||||
disabled={isVerifying}
|
||||
onClick={handlePasskeyBridge}
|
||||
data-flx="auth.sudo-verification-modal.password-manager-passkey-action"
|
||||
/>
|
||||
{legacyLinkActive && legacyLinkFollowed && !webAuthnError && (
|
||||
<p className={styles.passkeyHint} data-flx="auth.sudo-verification-modal.passkey-hint">
|
||||
{i18n._(FINISH_IN_THE_NEW_TAB_DESCRIPTOR)}
|
||||
</p>
|
||||
)}
|
||||
{webAuthnError && (
|
||||
<p className={styles.formError} role="alert" data-flx="auth.sudo-verification-modal.form-error">
|
||||
|
||||
+1
@@ -141,6 +141,7 @@ export const OAuthAuthorizeFlowPanel: React.FC<OAuthAuthorizeFlowPanelProps> = o
|
||||
<OAuthScopesStep
|
||||
authParams={authParams}
|
||||
botInviteWithoutRedirect={flow.botInviteWithoutRedirect}
|
||||
cannotSubmit={flow.cannotSubmit}
|
||||
clientLabel={flow.clientLabel}
|
||||
hasNextStep={flow.hasNextStep}
|
||||
hasPreviousStep={flow.hasPreviousStep}
|
||||
|
||||
+12
-3
@@ -29,6 +29,7 @@ const REQUIRED_DESCRIPTOR = msg({
|
||||
interface OAuthScopesStepProps {
|
||||
authParams: AuthorizeParams;
|
||||
botInviteWithoutRedirect: boolean;
|
||||
cannotSubmit: boolean;
|
||||
clientLabel: string;
|
||||
hasNextStep: boolean;
|
||||
hasPreviousStep: boolean;
|
||||
@@ -51,6 +52,7 @@ interface OAuthScopesStepProps {
|
||||
export const OAuthScopesStep: React.FC<OAuthScopesStepProps> = ({
|
||||
authParams,
|
||||
botInviteWithoutRedirect,
|
||||
cannotSubmit,
|
||||
clientLabel,
|
||||
hasNextStep,
|
||||
hasPreviousStep,
|
||||
@@ -151,10 +153,16 @@ export const OAuthScopesStep: React.FC<OAuthScopesStepProps> = ({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{scopesAdjusted && (
|
||||
<div className={styles.caution} data-flx="auth.o-auth-authorize-page.caution--2">
|
||||
<Trans>Turning off scopes may prevent the app from working correctly.</Trans>
|
||||
{scopes.length > 0 && selectedScopes.size === 0 ? (
|
||||
<div className={styles.caution} data-flx="auth.o-auth-authorize-page.caution--no-scopes">
|
||||
<Trans>Turn on at least one scope to authorize this app.</Trans>
|
||||
</div>
|
||||
) : (
|
||||
scopesAdjusted && (
|
||||
<div className={styles.caution} data-flx="auth.o-auth-authorize-page.caution--2">
|
||||
<Trans>Turning off scopes may prevent the app from working correctly.</Trans>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,6 +173,7 @@ export const OAuthScopesStep: React.FC<OAuthScopesStepProps> = ({
|
||||
showRedirectNotice={showRedirectNotice}
|
||||
hasPreviousStep={hasPreviousStep}
|
||||
hasNextStep={hasNextStep}
|
||||
authorizeDisabled={cannotSubmit}
|
||||
onAuthorize={onAuthorize}
|
||||
onBack={onBack}
|
||||
onCancel={onCancel}
|
||||
|
||||
+3
-3
@@ -306,7 +306,7 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
() => destinations.options.find((option) => option.value === selectedDestinationKey) ?? null,
|
||||
[destinations.options, selectedDestinationKey],
|
||||
);
|
||||
const cannotSubmit = hasBotScope && !selectedDestination;
|
||||
const cannotSubmit = scopeSelection.selected.size === 0 || (hasBotScope && !selectedDestination);
|
||||
const needsPermissionsStep =
|
||||
hasBotScope && selectedDestination?.kind !== 'group_dm' && permissionSelection.requestedKeys.length > 0;
|
||||
const hasRequestedBotPermissions =
|
||||
@@ -350,9 +350,9 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
setSubmitError(null);
|
||||
setSubmitting('approve');
|
||||
try {
|
||||
const scopeToSend = scopeSelection.toScopeString() || params.scope;
|
||||
const scopeToSend = scopeSelection.toScopeString();
|
||||
const sendsBotScope = scopeToSend.split(/[\s+]+/).includes('bot');
|
||||
if (sendsBotScope && !selectedDestination) {
|
||||
if (!scopeToSend || (sendsBotScope && !selectedDestination)) {
|
||||
setSubmitting(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {ConnectedHandoffApprovalFlow} from '@app/features/auth/flow/HandoffAppro
|
||||
import IpAuthorizationScreen from '@app/features/auth/flow/IpAuthorizationScreen';
|
||||
import {useAuthCardPresentation} from '@app/features/auth/flow/useAuthCardPresentation';
|
||||
import {useLoginFormController} from '@app/features/auth/hooks/useLoginFlow';
|
||||
import {usePasskeyBridgeReturn} from '@app/features/auth/passkey_migration/usePasskeyBridgeReturn';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import {
|
||||
type IpAuthorizationChallenge,
|
||||
@@ -35,7 +36,11 @@ import {
|
||||
startSsoLogin,
|
||||
} from '@app/features/auth/state/AuthFlow';
|
||||
import {shouldOfferOldAppSignIn} from '@app/features/auth/utils/OldAppSignIn';
|
||||
import {NEED_ACCOUNT_DESCRIPTOR, SIGN_IN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {
|
||||
COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR,
|
||||
NEED_ACCOUNT_DESCRIPTOR,
|
||||
SIGN_IN_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {type Account, SessionExpiredError} from '@app/features/platform/state/AuthSession';
|
||||
@@ -162,28 +167,29 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
},
|
||||
[desktopHandoff, handoff, onLoginComplete],
|
||||
);
|
||||
const {
|
||||
form,
|
||||
isLoading,
|
||||
fieldErrors,
|
||||
handlePasskeyLogin,
|
||||
handlePasskeyBrowserLogin,
|
||||
handlePasskeyBridgeLogin,
|
||||
passkeyBridgeSuggested,
|
||||
isPasskeyLoading,
|
||||
} = useLoginFormController({
|
||||
const {form, isLoading, fieldErrors, handlePasskeyLogin, handlePasskeyBrowserLogin, isPasskeyLoading} =
|
||||
useLoginFormController({
|
||||
redirectPath,
|
||||
inviteCode,
|
||||
onLoginSuccess: handleLoginSuccess,
|
||||
onRequireMfa: (challenge) => {
|
||||
AuthenticationCommands.setMfaTicket(challenge);
|
||||
},
|
||||
onRequireIpAuthorization: (challenge) => {
|
||||
setIpAuthChallenge(challenge);
|
||||
},
|
||||
});
|
||||
const isPasskeyBridgeRedeeming = usePasskeyBridgeReturn({
|
||||
redirectPath,
|
||||
inviteCode,
|
||||
onLoginSuccess: handleLoginSuccess,
|
||||
onRequireMfa: (challenge) => {
|
||||
AuthenticationCommands.setMfaTicket(challenge);
|
||||
},
|
||||
onRequireIpAuthorization: (challenge) => {
|
||||
setIpAuthChallenge(challenge);
|
||||
onRequireMfa: AuthenticationCommands.setMfaTicket,
|
||||
onFailure: () => {
|
||||
setSwitchError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR));
|
||||
},
|
||||
});
|
||||
const showBrowserPasskey = IS_DEV || isDesktop();
|
||||
const passkeyControlsDisabled = isLoading || Boolean(form.isSubmitting) || isPasskeyLoading;
|
||||
const passkeyControlsDisabled =
|
||||
isLoading || Boolean(form.isSubmitting) || isPasskeyLoading || isPasskeyBridgeRedeeming;
|
||||
const offerOldAppSignIn = useMemo(
|
||||
() =>
|
||||
!desktopHandoff &&
|
||||
@@ -414,7 +420,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
</AuthRouterLink>
|
||||
) : null
|
||||
}
|
||||
disableSubmit={isPasskeyLoading}
|
||||
disableSubmit={isPasskeyLoading || isPasskeyBridgeRedeeming}
|
||||
data-flx="auth.flow.auth-login-layout.auth-login-email-password-form"
|
||||
/>
|
||||
<AuthLoginDivider
|
||||
@@ -433,8 +439,6 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
onPasskeyLogin={handlePasskeyLogin}
|
||||
showBrowserOption={showBrowserPasskey}
|
||||
onBrowserLogin={handlePasskeyBrowserLogin}
|
||||
onPasswordManagerLogin={handlePasskeyBridgeLogin}
|
||||
passwordManagerSuggested={passkeyBridgeSuggested}
|
||||
browserLabel={i18n._(SIGN_IN_VIA_BROWSER_DESCRIPTOR)}
|
||||
data-flx="auth.flow.auth-login-layout.auth-login-passkey-actions"
|
||||
/>
|
||||
|
||||
@@ -30,9 +30,6 @@
|
||||
}
|
||||
|
||||
.webauthnSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {MFA_CODE_DIGIT_COUNT} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import FormField from '@app/features/auth/flow/AuthFormField';
|
||||
import styles from '@app/features/auth/flow/MfaScreen.module.css';
|
||||
import {useAuthCardPresentation} from '@app/features/auth/flow/useAuthCardPresentation';
|
||||
@@ -54,16 +53,7 @@ interface MfaScreenProps {
|
||||
|
||||
const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const {
|
||||
form,
|
||||
isLoading,
|
||||
fieldErrors,
|
||||
handleWebAuthn,
|
||||
handlePasskeyBridge,
|
||||
passkeyBridgeSuggested,
|
||||
isWebAuthnLoading,
|
||||
supports,
|
||||
} = useMfaController({
|
||||
const {form, isLoading, fieldErrors, handleWebAuthn, isWebAuthnLoading, supports} = useMfaController({
|
||||
ticket: challenge.ticket,
|
||||
methods: {totp: challenge.totp, webauthn: challenge.webauthn, backupCodes: challenge.backupCodes},
|
||||
inviteCode,
|
||||
@@ -138,12 +128,6 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps)
|
||||
>
|
||||
{i18n._(isCodePrimary ? TRY_SECURITY_KEY_INSTEAD_DESCRIPTOR : SECURITY_KEY_OR_PASSKEY_DESCRIPTOR)}
|
||||
</Button>
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passkeyBridgeSuggested}
|
||||
disabled={isWebAuthnLoading}
|
||||
onClick={handlePasskeyBridge}
|
||||
data-flx="auth.flow.mfa-screen.password-manager-passkey-action"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.footerButtons} data-flx="auth.flow.mfa-screen.footer-buttons">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {BrowserIcon, KeyIcon} from '@phosphor-icons/react';
|
||||
@@ -51,8 +50,6 @@ interface Props {
|
||||
onPasskeyLogin: () => void;
|
||||
showBrowserOption: boolean;
|
||||
onBrowserLogin?: () => void;
|
||||
onPasswordManagerLogin?: () => void;
|
||||
passwordManagerSuggested?: boolean;
|
||||
primaryLabel?: React.ReactNode;
|
||||
browserLabel?: React.ReactNode;
|
||||
}
|
||||
@@ -63,8 +60,6 @@ export default function AuthLoginPasskeyActions({
|
||||
onPasskeyLogin,
|
||||
showBrowserOption,
|
||||
onBrowserLogin,
|
||||
onPasswordManagerLogin,
|
||||
passwordManagerSuggested = false,
|
||||
primaryLabel = <Trans>Sign in with a passkey</Trans>,
|
||||
browserLabel = <Trans>Sign in via browser</Trans>,
|
||||
}: Props) {
|
||||
@@ -96,14 +91,6 @@ export default function AuthLoginPasskeyActions({
|
||||
{browserLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{onPasswordManagerLogin ? (
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passwordManagerSuggested}
|
||||
disabled={disabled}
|
||||
onClick={onPasswordManagerLogin}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-passkey-actions.password-manager-passkey-action"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
import {showBrowserLoginHandoffModal} from '@app/features/auth/flow/BrowserLoginHandoffModal';
|
||||
import {useAuthForm} from '@app/features/auth/hooks/useAuthForm';
|
||||
import {CaptchaCancelledError} from '@app/features/auth/hooks/useCaptcha';
|
||||
import {
|
||||
isPasskeyCeremonyDismissed,
|
||||
runPasskeyBridgeNativeLogin,
|
||||
startPasskeyBridgePageLogin,
|
||||
} from '@app/features/auth/passkey_migration/PasskeyLegacyCeremony';
|
||||
import {readPasskeyLoginRoute, writePasskeyLoginRoute} from '@app/features/auth/passkey_migration/PasskeyLoginRoute';
|
||||
import {isPasskeyMigrationOrigin, rpIdMatchesPage} from '@app/features/auth/passkey_migration/PasskeyMigrationOrigin';
|
||||
import {
|
||||
authenticateMfaWithWebAuthn,
|
||||
authenticateWithWebAuthn,
|
||||
@@ -15,19 +22,13 @@ import {
|
||||
loginWithMfaCode,
|
||||
loginWithPassword,
|
||||
type MfaChallenge,
|
||||
toLoginSuccessPayload,
|
||||
} from '@app/features/auth/state/AuthFlow';
|
||||
import {
|
||||
describePasskeyBridgeFailure,
|
||||
runPasskeyViaBridge,
|
||||
shouldSuggestPasskeyBridge,
|
||||
} from '@app/features/auth/utils/PasskeyBridge';
|
||||
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
import {useCallback, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const logger = Logger.create('useLoginFlow');
|
||||
@@ -57,6 +58,20 @@ export function useLoginCompletion(mode: LoginCompletionMode) {
|
||||
return {completeLogin};
|
||||
}
|
||||
|
||||
type LegacyPasskeyLoginOutcome = LoginSuccessPayload | 'cancelled' | 'navigating';
|
||||
|
||||
async function runLegacyPasskeyLogin(mfa: MfaChallenge | null): Promise<LegacyPasskeyLoginOutcome> {
|
||||
if (!Platform.isElectron) {
|
||||
await startPasskeyBridgePageLogin(mfa, `${window.location.pathname}${window.location.search}`);
|
||||
return 'navigating';
|
||||
}
|
||||
const result =
|
||||
mfa === null
|
||||
? await runPasskeyBridgeNativeLogin('login')
|
||||
: await runPasskeyBridgeNativeLogin('login_mfa', mfa.ticket);
|
||||
return result.status === 'completed' ? toLoginSuccessPayload(result) : 'cancelled';
|
||||
}
|
||||
|
||||
const handleLoginOutcome = async (
|
||||
result: LoginResult,
|
||||
onLoginSuccess?: (payload: LoginSuccessPayload) => Promise<void> | void,
|
||||
@@ -95,9 +110,7 @@ export function useLoginFormController({
|
||||
onRequireMfa,
|
||||
onRequireIpAuthorization,
|
||||
}: LoginFormControllerOptions) {
|
||||
const {i18n} = useLingui();
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const {form, isLoading, fieldErrors, error} = useAuthForm({
|
||||
initialValues: {email: '', password: ''},
|
||||
onSubmit: async (values) => {
|
||||
@@ -119,69 +132,63 @@ export function useLoginFormController({
|
||||
}
|
||||
});
|
||||
}, [onLoginSuccess, redirectPath]);
|
||||
const completePasskeyLogin = useCallback(
|
||||
async (options: PublicKeyCredentialRequestOptionsJSON, credential: AuthenticationResponseJSON) => {
|
||||
const response = await authenticateWithWebAuthn({
|
||||
response: credential,
|
||||
challenge: options.challenge,
|
||||
inviteCode,
|
||||
});
|
||||
await onLoginSuccess?.(response);
|
||||
const handlePasskeyLogin = useCallback(async () => {
|
||||
setIsPasskeyLoading(true);
|
||||
const migrationOrigin = isPasskeyMigrationOrigin();
|
||||
let navigating = false;
|
||||
try {
|
||||
let outcome: LegacyPasskeyLoginOutcome | null = null;
|
||||
if (!migrationOrigin || readPasskeyLoginRoute() === 'native') {
|
||||
await WebAuthnUtils.assertWebAuthnSupported();
|
||||
const options = await getWebAuthnAuthenticationOptions();
|
||||
const credential = await WebAuthnUtils.performAuthentication(options).catch((error: unknown) => {
|
||||
if (migrationOrigin && isPasskeyCeremonyDismissed(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (credential !== null) {
|
||||
outcome = await authenticateWithWebAuthn({
|
||||
response: credential,
|
||||
challenge: options.challenge,
|
||||
inviteCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (outcome === null) {
|
||||
writePasskeyLoginRoute('legacy');
|
||||
outcome = await runLegacyPasskeyLogin(null).catch((error: unknown) => {
|
||||
writePasskeyLoginRoute('native');
|
||||
throw error;
|
||||
});
|
||||
if (outcome === 'cancelled') {
|
||||
writePasskeyLoginRoute('native');
|
||||
}
|
||||
}
|
||||
navigating = outcome === 'navigating';
|
||||
if (typeof outcome === 'string') {
|
||||
return;
|
||||
}
|
||||
await onLoginSuccess?.(outcome);
|
||||
if (redirectPath) {
|
||||
RouterUtils.replaceWith(redirectPath);
|
||||
}
|
||||
},
|
||||
[inviteCode, onLoginSuccess, redirectPath],
|
||||
);
|
||||
const handlePasskeyLogin = useCallback(async () => {
|
||||
setIsPasskeyLoading(true);
|
||||
try {
|
||||
await WebAuthnUtils.assertWebAuthnSupported();
|
||||
const options = await getWebAuthnAuthenticationOptions();
|
||||
const credential = await WebAuthnUtils.performAuthentication(options);
|
||||
await completePasskeyLogin(options, credential);
|
||||
} catch (err) {
|
||||
if (err instanceof CaptchaCancelledError) {
|
||||
return;
|
||||
}
|
||||
logger.error('Passkey login failed', err);
|
||||
if (shouldSuggestPasskeyBridge(err)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
return;
|
||||
}
|
||||
if (err instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
|
||||
ToastCommands.error(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
const userCancelled =
|
||||
err instanceof DOMException && (err.name === 'NotAllowedError' || err.name === 'AbortError');
|
||||
if (isDesktop() && !userCancelled) {
|
||||
handleDesktopPasskeyHandoff();
|
||||
}
|
||||
} finally {
|
||||
setIsPasskeyLoading(false);
|
||||
}
|
||||
}, [completePasskeyLogin, handleDesktopPasskeyHandoff, i18n]);
|
||||
const handlePasskeyBridgeLogin = useCallback(() => {
|
||||
const options = getWebAuthnAuthenticationOptions();
|
||||
const credential = runPasskeyViaBridge('authenticate', options);
|
||||
setIsPasskeyLoading(true);
|
||||
Promise.all([options, credential])
|
||||
.then(([resolvedOptions, resolvedCredential]) => completePasskeyLogin(resolvedOptions, resolvedCredential))
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof CaptchaCancelledError) {
|
||||
return;
|
||||
}
|
||||
logger.error('Passkey login in the pop-up window failed', err);
|
||||
const descriptor = describePasskeyBridgeFailure(err);
|
||||
if (descriptor) {
|
||||
ToastCommands.error(i18n._(descriptor));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!navigating) {
|
||||
setIsPasskeyLoading(false);
|
||||
});
|
||||
}, [completePasskeyLogin, i18n]);
|
||||
}
|
||||
}
|
||||
}, [inviteCode, onLoginSuccess, redirectPath, handleDesktopPasskeyHandoff]);
|
||||
return {
|
||||
form,
|
||||
isLoading,
|
||||
@@ -189,8 +196,6 @@ export function useLoginFormController({
|
||||
error,
|
||||
handlePasskeyLogin,
|
||||
handlePasskeyBrowserLogin: handleDesktopPasskeyHandoff,
|
||||
handlePasskeyBridgeLogin,
|
||||
passkeyBridgeSuggested,
|
||||
isPasskeyLoading,
|
||||
};
|
||||
}
|
||||
@@ -207,9 +212,8 @@ interface MfaControllerOptions {
|
||||
}
|
||||
|
||||
export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}: MfaControllerOptions) {
|
||||
const {i18n} = useLingui();
|
||||
const [isWebAuthnLoading, setIsWebAuthnLoading] = useState(false);
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const preferLegacyRef = useRef(false);
|
||||
const {form, isLoading, fieldErrors} = useAuthForm({
|
||||
initialValues: {code: ''},
|
||||
onSubmit: async (values) => {
|
||||
@@ -227,54 +231,51 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
|
||||
firstFieldName: 'code',
|
||||
redirectPath: undefined,
|
||||
});
|
||||
const completeWebAuthnMfa = useCallback(
|
||||
async (options: PublicKeyCredentialRequestOptionsJSON, credential: AuthenticationResponseJSON) => {
|
||||
const response = await authenticateMfaWithWebAuthn({
|
||||
response: credential,
|
||||
challenge: options.challenge,
|
||||
ticket,
|
||||
inviteCode,
|
||||
});
|
||||
await onLoginSuccess?.(response);
|
||||
},
|
||||
[inviteCode, onLoginSuccess, ticket],
|
||||
);
|
||||
const handleWebAuthn = useCallback(async () => {
|
||||
setIsWebAuthnLoading(true);
|
||||
let navigating = false;
|
||||
try {
|
||||
const options = await getWebAuthnMfaOptions(ticket);
|
||||
const credential = await WebAuthnUtils.performAuthentication(options);
|
||||
await completeWebAuthnMfa(options, credential);
|
||||
const migrationOrigin = isPasskeyMigrationOrigin();
|
||||
const runsOnPage =
|
||||
!migrationOrigin || (!preferLegacyRef.current && (options.rpId === undefined || rpIdMatchesPage(options.rpId)));
|
||||
let response: LoginSuccessPayload;
|
||||
if (runsOnPage) {
|
||||
const credential = await WebAuthnUtils.performAuthentication(options).catch((error: unknown) => {
|
||||
if (migrationOrigin && isPasskeyCeremonyDismissed(error)) {
|
||||
preferLegacyRef.current = true;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
response = await authenticateMfaWithWebAuthn({
|
||||
response: credential,
|
||||
challenge: options.challenge,
|
||||
ticket,
|
||||
inviteCode,
|
||||
});
|
||||
} else {
|
||||
const outcome = await runLegacyPasskeyLogin({ticket, ...methods}).catch((error: unknown) => {
|
||||
preferLegacyRef.current = false;
|
||||
throw error;
|
||||
});
|
||||
navigating = outcome === 'navigating';
|
||||
if (outcome === 'cancelled') {
|
||||
preferLegacyRef.current = false;
|
||||
}
|
||||
if (typeof outcome === 'string') {
|
||||
return;
|
||||
}
|
||||
response = outcome;
|
||||
}
|
||||
await onLoginSuccess?.(response);
|
||||
} catch (error) {
|
||||
logger.error('WebAuthn MFA failed', error);
|
||||
if (shouldSuggestPasskeyBridge(error)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
return;
|
||||
}
|
||||
if (error instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
|
||||
ToastCommands.error(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
|
||||
}
|
||||
} finally {
|
||||
setIsWebAuthnLoading(false);
|
||||
}
|
||||
}, [completeWebAuthnMfa, ticket, i18n]);
|
||||
const handlePasskeyBridge = useCallback(() => {
|
||||
const options = getWebAuthnMfaOptions(ticket);
|
||||
const credential = runPasskeyViaBridge('authenticate', options);
|
||||
setIsWebAuthnLoading(true);
|
||||
Promise.all([options, credential])
|
||||
.then(([resolvedOptions, resolvedCredential]) => completeWebAuthnMfa(resolvedOptions, resolvedCredential))
|
||||
.catch((error: unknown) => {
|
||||
logger.error('WebAuthn MFA in the pop-up window failed', error);
|
||||
const descriptor = describePasskeyBridgeFailure(error);
|
||||
if (descriptor) {
|
||||
ToastCommands.error(i18n._(descriptor));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!navigating) {
|
||||
setIsWebAuthnLoading(false);
|
||||
});
|
||||
}, [completeWebAuthnMfa, ticket, i18n]);
|
||||
}
|
||||
}
|
||||
}, [inviteCode, methods, onLoginSuccess, ticket]);
|
||||
const supports = useMemo(
|
||||
() => ({totp: methods.totp, webauthn: methods.webauthn, backupCodes: methods.backupCodes}),
|
||||
[methods.totp, methods.webauthn, methods.backupCodes],
|
||||
@@ -284,8 +285,6 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
|
||||
isLoading,
|
||||
fieldErrors,
|
||||
handleWebAuthn,
|
||||
handlePasskeyBridge,
|
||||
passkeyBridgeSuggested,
|
||||
isWebAuthnLoading,
|
||||
supports,
|
||||
};
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/ErrorFallback.module.css';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {
|
||||
type PasskeyBridgeSession,
|
||||
type PasskeyBridgeViewState,
|
||||
startPasskeyBridgeSession,
|
||||
} from '@app/features/auth/passkey_bridge/PasskeyBridgeSession';
|
||||
import {CONTINUE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
const USE_YOUR_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Use your passkey',
|
||||
comment: 'Heading of the small pop-up window that runs a passkey prompt for the new web address.',
|
||||
});
|
||||
const CONTINUE_TO_SIGN_IN_DESCRIPTOR = msg({
|
||||
message: 'Continue with your passkey to sign in to {host}',
|
||||
comment:
|
||||
'Body of the passkey pop-up window when signing in or confirming identity. host is a web address such as fluxer.com.',
|
||||
});
|
||||
const CONTINUE_TO_CREATE_DESCRIPTOR = msg({
|
||||
message: 'Continue to create a passkey for {host}',
|
||||
comment: 'Body of the passkey pop-up window when adding a new passkey. host is a web address such as fluxer.com.',
|
||||
});
|
||||
const CLOSE_WINDOW_DESCRIPTOR = msg({
|
||||
message: 'Close window',
|
||||
comment: 'Button in the passkey pop-up window that closes it after an error.',
|
||||
});
|
||||
const BRIDGE_UNAVAILABLE_DESCRIPTOR = msg({
|
||||
message: 'Open this window from {productName} to use your passkey.',
|
||||
comment: 'Error in the passkey pop-up window when someone opens its address directly. productName is the app name.',
|
||||
});
|
||||
const BRIDGE_REJECTED_DESCRIPTOR = msg({
|
||||
message: 'This passkey request could not be verified. Close this window and try again.',
|
||||
comment: 'Error in the passkey pop-up window when the request it received is not valid.',
|
||||
});
|
||||
const BRIDGE_TIMED_OUT_DESCRIPTOR = msg({
|
||||
message: 'This passkey request timed out. Close this window and try again.',
|
||||
comment: 'Error in the passkey pop-up window after five minutes without finishing.',
|
||||
});
|
||||
|
||||
interface PasskeyBridgeScreenProps {
|
||||
openerOrigin: string;
|
||||
}
|
||||
|
||||
export const PasskeyBridgeScreen: React.FC<PasskeyBridgeScreenProps> = ({openerOrigin}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [state, setState] = useState<PasskeyBridgeViewState>({status: 'waiting'});
|
||||
const sessionRef = useRef<PasskeyBridgeSession | null>(null);
|
||||
useEffect(() => {
|
||||
const session = startPasskeyBridgeSession(openerOrigin, setState);
|
||||
sessionRef.current = session;
|
||||
return () => {
|
||||
session.dispose();
|
||||
sessionRef.current = null;
|
||||
};
|
||||
}, [openerOrigin]);
|
||||
const handleContinue = useCallback(() => {
|
||||
sessionRef.current?.continueCeremony();
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
window.close();
|
||||
}, []);
|
||||
const host = new URL(openerOrigin).host;
|
||||
const failure =
|
||||
state.status === 'unavailable'
|
||||
? i18n._(BRIDGE_UNAVAILABLE_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
: state.status === 'rejected'
|
||||
? i18n._(BRIDGE_REJECTED_DESCRIPTOR)
|
||||
: state.status === 'timed_out'
|
||||
? i18n._(BRIDGE_TIMED_OUT_DESCRIPTOR)
|
||||
: null;
|
||||
const kind = state.status === 'ready' || state.status === 'running' ? state.kind : 'authenticate';
|
||||
return (
|
||||
<main className={styles.errorFallbackContainer} data-flx="auth.passkey-bridge-screen.container">
|
||||
<FluxerIcon className={styles.errorFallbackIcon} data-flx="auth.passkey-bridge-screen.icon" />
|
||||
<div className={styles.errorFallbackContent} data-flx="auth.passkey-bridge-screen.content">
|
||||
<h1 className={styles.errorFallbackTitle} data-flx="auth.passkey-bridge-screen.title">
|
||||
{i18n._(USE_YOUR_PASSKEY_DESCRIPTOR)}
|
||||
</h1>
|
||||
<p
|
||||
className={styles.errorFallbackDescription}
|
||||
role={failure === null ? undefined : 'alert'}
|
||||
data-flx="auth.passkey-bridge-screen.description"
|
||||
>
|
||||
{failure ??
|
||||
i18n._(kind === 'register' ? CONTINUE_TO_CREATE_DESCRIPTOR : CONTINUE_TO_SIGN_IN_DESCRIPTOR, {host})}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.errorFallbackActions} data-flx="auth.passkey-bridge-screen.actions">
|
||||
{failure === null ? (
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
disabled={state.status !== 'ready'}
|
||||
submitting={state.status === 'waiting' || state.status === 'running'}
|
||||
autoFocus
|
||||
data-flx="auth.passkey-bridge-screen.button.continue"
|
||||
>
|
||||
{i18n._(CONTINUE_DESCRIPTOR)}
|
||||
</Button>
|
||||
) : state.status !== 'unavailable' ? (
|
||||
<Button variant="secondary" onClick={handleClose} data-flx="auth.passkey-bridge-screen.button.close">
|
||||
{i18n._(CLOSE_WINDOW_DESCRIPTOR)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
PASSKEY_BRIDGE_READY_TYPE,
|
||||
PASSKEY_BRIDGE_RESULT_TYPE,
|
||||
PASSKEY_BRIDGE_TIMEOUT_MS,
|
||||
PASSKEY_BRIDGE_VERSION,
|
||||
type PasskeyBridgeKind,
|
||||
type PasskeyBridgeRequest,
|
||||
type PasskeyBridgeResponseMap,
|
||||
type PasskeyBridgeResult,
|
||||
parsePasskeyBridgeRequest,
|
||||
readPasskeyBridgeRequestId,
|
||||
toPasskeyBridgeErrorPayload,
|
||||
} from '@app/features/auth/utils/PasskeyBridgeProtocol';
|
||||
import {startAuthentication, startRegistration} from '@simplewebauthn/browser';
|
||||
|
||||
export type PasskeyBridgeViewState =
|
||||
| {status: 'unavailable'}
|
||||
| {status: 'waiting'}
|
||||
| {status: 'ready'; kind: PasskeyBridgeKind}
|
||||
| {status: 'running'; kind: PasskeyBridgeKind}
|
||||
| {status: 'rejected'}
|
||||
| {status: 'timed_out'};
|
||||
|
||||
export interface PasskeyBridgeSession {
|
||||
continueCeremony(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
function runCeremony(request: PasskeyBridgeRequest): Promise<PasskeyBridgeResponseMap[PasskeyBridgeKind]> {
|
||||
if (request.kind === 'authenticate') {
|
||||
return startAuthentication({optionsJSON: request.options});
|
||||
}
|
||||
return startRegistration({optionsJSON: request.options});
|
||||
}
|
||||
|
||||
function failureResult(id: string, error: unknown): PasskeyBridgeResult {
|
||||
return {
|
||||
type: PASSKEY_BRIDGE_RESULT_TYPE,
|
||||
v: PASSKEY_BRIDGE_VERSION,
|
||||
id,
|
||||
ok: false,
|
||||
error: toPasskeyBridgeErrorPayload(error),
|
||||
};
|
||||
}
|
||||
|
||||
export function startPasskeyBridgeSession(
|
||||
openerOrigin: string,
|
||||
onStateChange: (state: PasskeyBridgeViewState) => void,
|
||||
): PasskeyBridgeSession {
|
||||
const opener = window.opener as Window | null;
|
||||
if (opener === null || opener === window) {
|
||||
onStateChange({status: 'unavailable'});
|
||||
return {continueCeremony() {}, dispose() {}};
|
||||
}
|
||||
let request: PasskeyBridgeRequest | null = null;
|
||||
let running = false;
|
||||
let finished = false;
|
||||
const post = (message: unknown) => {
|
||||
opener.postMessage(message, openerOrigin);
|
||||
};
|
||||
const finish = (result: PasskeyBridgeResult) => {
|
||||
finished = true;
|
||||
window.clearTimeout(timeout);
|
||||
post(result);
|
||||
window.close();
|
||||
};
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== openerOrigin || event.source !== opener) {
|
||||
return;
|
||||
}
|
||||
window.removeEventListener('message', handleMessage);
|
||||
const parsed = parsePasskeyBridgeRequest(event.data);
|
||||
if (parsed === null) {
|
||||
finished = true;
|
||||
window.clearTimeout(timeout);
|
||||
const id = readPasskeyBridgeRequestId(event.data);
|
||||
if (id !== null) {
|
||||
post(failureResult(id, new DOMException('The passkey request was rejected', 'SecurityError')));
|
||||
}
|
||||
onStateChange({status: 'rejected'});
|
||||
return;
|
||||
}
|
||||
request = parsed;
|
||||
onStateChange({status: 'ready', kind: parsed.kind});
|
||||
};
|
||||
const timeout = window.setTimeout(() => {
|
||||
if (running || finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
window.removeEventListener('message', handleMessage);
|
||||
if (request !== null) {
|
||||
post(failureResult(request.id, new DOMException('The passkey window timed out', 'TimeoutError')));
|
||||
request = null;
|
||||
}
|
||||
onStateChange({status: 'timed_out'});
|
||||
}, PASSKEY_BRIDGE_TIMEOUT_MS);
|
||||
window.addEventListener('message', handleMessage);
|
||||
onStateChange({status: 'waiting'});
|
||||
post({type: PASSKEY_BRIDGE_READY_TYPE, v: PASSKEY_BRIDGE_VERSION});
|
||||
return {
|
||||
continueCeremony() {
|
||||
const current = request;
|
||||
if (current === null || running || finished) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
onStateChange({status: 'running', kind: current.kind});
|
||||
runCeremony(current).then(
|
||||
(response) => {
|
||||
finish({type: PASSKEY_BRIDGE_RESULT_TYPE, v: PASSKEY_BRIDGE_VERSION, id: current.id, ok: true, response});
|
||||
},
|
||||
(error: unknown) => {
|
||||
finish(failureResult(current.id, error));
|
||||
},
|
||||
);
|
||||
},
|
||||
dispose() {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
window.clearTimeout(timeout);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/ErrorFallback.module.css';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import type {DomainMigrationSide} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import {
|
||||
readPasskeyBridgePageLoginReturnPath,
|
||||
readPasskeyBridgeReturn,
|
||||
} from '@app/features/auth/passkey_migration/PasskeyBridgeReturn';
|
||||
import {
|
||||
CANCEL_DESCRIPTOR,
|
||||
CONTINUE_DESCRIPTOR,
|
||||
TRY_AGAIN_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
|
||||
import {PASSKEY_BRIDGE_CHANNEL} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import type {PasskeyBridgeFinishResponse} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {type PublicKeyCredentialRequestOptionsJSON, startAuthentication} from '@simplewebauthn/browser';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
const CEREMONY_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
|
||||
|
||||
const USE_YOUR_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Use your passkey',
|
||||
comment: 'Heading of the page that asks the user to confirm with their passkey.',
|
||||
});
|
||||
const PRESS_CONTINUE_DESCRIPTOR = msg({
|
||||
message: 'Press Continue to use your passkey.',
|
||||
comment: 'Body of the page that asks the user to confirm with their passkey. Continue is the button label.',
|
||||
});
|
||||
const THAT_DIDN_T_WORK_DESCRIPTOR = msg({
|
||||
message: "That didn't work. Try again or cancel.",
|
||||
comment: 'Body of the passkey page after the passkey prompt failed or was dismissed. Keep plain.',
|
||||
});
|
||||
const REQUEST_EXPIRED_DESCRIPTOR = msg({
|
||||
message: 'This request has expired. Go back and try again.',
|
||||
comment: 'Body of the passkey page when the passkey request is no longer valid. Keep plain.',
|
||||
});
|
||||
const BACK_TO_PRODUCT_DESCRIPTOR = msg({
|
||||
message: 'Back to {productName}',
|
||||
comment: 'Button on the expired passkey page that returns to the app. productName is the app name.',
|
||||
});
|
||||
const YOU_CAN_CLOSE_THIS_TAB_DESCRIPTOR = msg({
|
||||
message: 'You can close this tab',
|
||||
comment: 'Heading of the page shown after a passkey confirmation finished in a separate tab.',
|
||||
});
|
||||
const GO_BACK_TO_CONTINUE_DESCRIPTOR = msg({
|
||||
message: 'Go back to {productName} to continue.',
|
||||
comment:
|
||||
'Body of the page shown after a passkey confirmation finished in a separate tab. productName is the app name.',
|
||||
});
|
||||
|
||||
type LegacyBridgeState =
|
||||
| {status: 'loading'}
|
||||
| {status: 'ready'; options: PublicKeyCredentialRequestOptionsJSON}
|
||||
| {status: 'running'}
|
||||
| {status: 'failed'}
|
||||
| {status: 'expired'};
|
||||
|
||||
class PasskeyBridgeExpiredError extends Error {
|
||||
constructor() {
|
||||
super('Passkey bridge ceremony is unknown or expired');
|
||||
this.name = 'PasskeyBridgeExpiredError';
|
||||
}
|
||||
}
|
||||
|
||||
async function postBridge<T>(path: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${window.location.origin}/api/v1${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'omit',
|
||||
...(body === undefined ? {} : {headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
throw new PasskeyBridgeExpiredError();
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Passkey bridge request failed with status ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function leave(finish: PasskeyBridgeFinishResponse): void {
|
||||
if (finish.return_url === null) {
|
||||
throw new Error('Passkey bridge finished without a return address');
|
||||
}
|
||||
window.location.replace(finish.return_url);
|
||||
}
|
||||
|
||||
const isDismissed = (error: unknown): boolean =>
|
||||
error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'AbortError');
|
||||
|
||||
interface PasskeyBridgePageProps {
|
||||
side: DomainMigrationSide;
|
||||
hash: string;
|
||||
opensInOwnTab: boolean;
|
||||
}
|
||||
|
||||
const LegacyPasskeyBridgePage: React.FC<PasskeyBridgePageProps> = ({side, hash, opensInOwnTab}) => {
|
||||
const {i18n} = useLingui();
|
||||
const ceremonyId = CEREMONY_ID_PATTERN.test(hash.slice(1)) ? hash.slice(1) : null;
|
||||
const [state, setState] = useState<LegacyBridgeState>(() =>
|
||||
ceremonyId === null ? {status: 'expired'} : {status: 'loading'},
|
||||
);
|
||||
const fail = useCallback((error: unknown) => {
|
||||
setState(error instanceof PasskeyBridgeExpiredError ? {status: 'expired'} : {status: 'failed'});
|
||||
}, []);
|
||||
const loadOptions = useCallback(() => {
|
||||
if (ceremonyId === null) {
|
||||
return;
|
||||
}
|
||||
setState({status: 'loading'});
|
||||
postBridge<{options: PublicKeyCredentialRequestOptionsJSON}>(Endpoints.AUTH_PASSKEY_BRIDGE_OPTIONS(ceremonyId))
|
||||
.then((response) => {
|
||||
setState({status: 'ready', options: response.options});
|
||||
})
|
||||
.catch(fail);
|
||||
}, [ceremonyId, fail]);
|
||||
useEffect(loadOptions, [loadOptions]);
|
||||
const cancel = useCallback(() => {
|
||||
if (ceremonyId === null) {
|
||||
return;
|
||||
}
|
||||
setState({status: 'running'});
|
||||
postBridge<PasskeyBridgeFinishResponse>(Endpoints.AUTH_PASSKEY_BRIDGE_CANCEL(ceremonyId))
|
||||
.then(leave)
|
||||
.catch(() => {
|
||||
setState({status: 'expired'});
|
||||
});
|
||||
}, [ceremonyId]);
|
||||
const handleContinue = useCallback(() => {
|
||||
if (ceremonyId === null || state.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
setState({status: 'running'});
|
||||
startAuthentication({optionsJSON: state.options}).then(
|
||||
(response) =>
|
||||
postBridge<PasskeyBridgeFinishResponse>(Endpoints.AUTH_PASSKEY_BRIDGE_COMPLETE(ceremonyId), {response})
|
||||
.then(leave)
|
||||
.catch((error: unknown) => {
|
||||
if (opensInOwnTab || error instanceof PasskeyBridgeExpiredError) {
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
cancel();
|
||||
}),
|
||||
(error: unknown) => {
|
||||
if (!opensInOwnTab && isDismissed(error)) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
setState({status: 'failed'});
|
||||
},
|
||||
);
|
||||
}, [cancel, ceremonyId, fail, opensInOwnTab, state]);
|
||||
const handleBack = useCallback(() => {
|
||||
window.close();
|
||||
window.location.replace(`${side.target}/`);
|
||||
}, [side.target]);
|
||||
const expired = state.status === 'expired';
|
||||
const failed = state.status === 'failed';
|
||||
return (
|
||||
<main className={styles.errorFallbackContainer} data-flx="auth.passkey-bridge-page.container">
|
||||
<FluxerIcon className={styles.errorFallbackIcon} data-flx="auth.passkey-bridge-page.icon" />
|
||||
<div className={styles.errorFallbackContent} data-flx="auth.passkey-bridge-page.content">
|
||||
<h1 className={styles.errorFallbackTitle} data-flx="auth.passkey-bridge-page.title">
|
||||
{i18n._(USE_YOUR_PASSKEY_DESCRIPTOR)}
|
||||
</h1>
|
||||
<p
|
||||
className={styles.errorFallbackDescription}
|
||||
role={expired || failed ? 'alert' : undefined}
|
||||
data-flx="auth.passkey-bridge-page.description"
|
||||
>
|
||||
{i18n._(
|
||||
expired ? REQUEST_EXPIRED_DESCRIPTOR : failed ? THAT_DIDN_T_WORK_DESCRIPTOR : PRESS_CONTINUE_DESCRIPTOR,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.errorFallbackActions} data-flx="auth.passkey-bridge-page.actions">
|
||||
{expired ? (
|
||||
<Button onClick={handleBack} autoFocus data-flx="auth.passkey-bridge-page.button.back">
|
||||
{i18n._(BACK_TO_PRODUCT_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
</Button>
|
||||
) : failed ? (
|
||||
<>
|
||||
<Button
|
||||
variant={opensInOwnTab ? 'secondary' : 'primary'}
|
||||
onClick={loadOptions}
|
||||
autoFocus={!opensInOwnTab}
|
||||
data-flx="auth.passkey-bridge-page.button.try-again"
|
||||
>
|
||||
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={opensInOwnTab ? 'primary' : 'ghost'}
|
||||
onClick={cancel}
|
||||
autoFocus={opensInOwnTab}
|
||||
data-flx="auth.passkey-bridge-page.button.cancel"
|
||||
>
|
||||
{i18n._(CANCEL_DESCRIPTOR)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
submitting={state.status !== 'ready'}
|
||||
autoFocus
|
||||
data-flx="auth.passkey-bridge-page.button.continue"
|
||||
>
|
||||
{i18n._(CONTINUE_DESCRIPTOR)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={cancel}
|
||||
disabled={state.status === 'running'}
|
||||
data-flx="auth.passkey-bridge-page.button.cancel"
|
||||
>
|
||||
{i18n._(CANCEL_DESCRIPTOR)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const TargetPasskeyBridgePage: React.FC<{hash: string}> = ({hash}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [bridgeReturn] = useState(() => readPasskeyBridgeReturn(hash));
|
||||
const [loginReturnPath] = useState(() =>
|
||||
bridgeReturn === null ? null : readPasskeyBridgePageLoginReturnPath(bridgeReturn.ceremonyId),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (loginReturnPath !== null) {
|
||||
window.location.replace(`${window.location.origin}${loginReturnPath}${hash}`);
|
||||
return;
|
||||
}
|
||||
if (bridgeReturn !== null) {
|
||||
const channel = new BroadcastChannel(PASSKEY_BRIDGE_CHANNEL);
|
||||
channel.postMessage({ceremony_id: bridgeReturn.ceremonyId, completion_code: bridgeReturn.completionCode});
|
||||
channel.close();
|
||||
}
|
||||
window.close();
|
||||
}, [bridgeReturn, hash, loginReturnPath]);
|
||||
if (loginReturnPath !== null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<main className={styles.errorFallbackContainer} data-flx="auth.passkey-bridge-page.container">
|
||||
<FluxerIcon className={styles.errorFallbackIcon} data-flx="auth.passkey-bridge-page.icon" />
|
||||
<div className={styles.errorFallbackContent} data-flx="auth.passkey-bridge-page.content">
|
||||
<h1 className={styles.errorFallbackTitle} data-flx="auth.passkey-bridge-page.title">
|
||||
{i18n._(YOU_CAN_CLOSE_THIS_TAB_DESCRIPTOR)}
|
||||
</h1>
|
||||
<p className={styles.errorFallbackDescription} data-flx="auth.passkey-bridge-page.description">
|
||||
{i18n._(GO_BACK_TO_CONTINUE_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export const PasskeyBridgePage: React.FC<PasskeyBridgePageProps> = ({side, hash, opensInOwnTab}) =>
|
||||
side.role === 'source' ? (
|
||||
<LegacyPasskeyBridgePage
|
||||
side={side}
|
||||
hash={hash}
|
||||
opensInOwnTab={opensInOwnTab}
|
||||
data-flx="auth.passkey-bridge-page.legacy"
|
||||
/>
|
||||
) : (
|
||||
<TargetPasskeyBridgePage hash={hash} data-flx="auth.passkey-bridge-page.target" />
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readPasskeyBridgeReturn} from '@app/features/auth/passkey_migration/PasskeyBridgeReturn';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const CEREMONY_ID = 'a'.repeat(43);
|
||||
const COMPLETION_CODE = 'B_-'.repeat(14).concat('c');
|
||||
|
||||
describe('readPasskeyBridgeReturn', () => {
|
||||
it('reads the ceremony id and completion code from the fragment', () => {
|
||||
expect(readPasskeyBridgeReturn(`#passkey-bridge=${CEREMONY_ID}.${COMPLETION_CODE}`)).toEqual({
|
||||
ceremonyId: CEREMONY_ID,
|
||||
completionCode: COMPLETION_CODE,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty fragment', ''],
|
||||
['another fragment', '#section'],
|
||||
['a missing completion code', `#passkey-bridge=${CEREMONY_ID}`],
|
||||
['an extra segment', `#passkey-bridge=${CEREMONY_ID}.${COMPLETION_CODE}.${COMPLETION_CODE}`],
|
||||
['a short ceremony id', `#passkey-bridge=abc.${COMPLETION_CODE}`],
|
||||
['characters outside base64url', `#passkey-bridge=${CEREMONY_ID}.${'+'.repeat(43)}`],
|
||||
])('rejects %s', (_label, hash) => {
|
||||
expect(readPasskeyBridgeReturn(hash)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MfaChallenge} from '@app/features/auth/state/AuthFlow';
|
||||
import {getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import {PASSKEY_BRIDGE_RETURN_FRAGMENT_KEY} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import type {PasskeyBridgeLoginStartRequest} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
|
||||
const BRIDGE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
|
||||
const STORED_LOGIN_KEY_PREFIX = 'fluxer:passkey-bridge:';
|
||||
const STORED_LOGIN_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
|
||||
export type PasskeyBridgeLoginPurpose = PasskeyBridgeLoginStartRequest['purpose'];
|
||||
|
||||
interface StoredPasskeyBridgePageLogin {
|
||||
nonce: string;
|
||||
purpose: PasskeyBridgeLoginPurpose;
|
||||
return_path: string;
|
||||
mfa: MfaChallenge | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface PasskeyBridgePageLogin {
|
||||
nonce: string;
|
||||
mfa: MfaChallenge | null;
|
||||
}
|
||||
|
||||
export interface PasskeyBridgeReturn {
|
||||
ceremonyId: string;
|
||||
completionCode: string;
|
||||
}
|
||||
|
||||
export function readPasskeyBridgeReturn(hash: string): PasskeyBridgeReturn | null {
|
||||
const value = new URLSearchParams(hash.startsWith('#') ? hash.slice(1) : hash).get(
|
||||
PASSKEY_BRIDGE_RETURN_FRAGMENT_KEY,
|
||||
);
|
||||
const parts = value?.split('.') ?? [];
|
||||
if (parts.length !== 2 || !parts.every((part) => BRIDGE_TOKEN_PATTERN.test(part))) {
|
||||
return null;
|
||||
}
|
||||
return {ceremonyId: parts[0], completionCode: parts[1]};
|
||||
}
|
||||
|
||||
function parseMfaChallenge(value: unknown): MfaChallenge | null {
|
||||
const mfa = value as Partial<MfaChallenge> | null;
|
||||
if (
|
||||
typeof mfa?.ticket !== 'string' ||
|
||||
typeof mfa.totp !== 'boolean' ||
|
||||
typeof mfa.webauthn !== 'boolean' ||
|
||||
typeof mfa.backupCodes !== 'boolean'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {ticket: mfa.ticket, totp: mfa.totp, webauthn: mfa.webauthn, backupCodes: mfa.backupCodes};
|
||||
}
|
||||
|
||||
function parseStoredLogin(raw: string | null): StoredPasskeyBridgePageLogin | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(raw) as Partial<StoredPasskeyBridgePageLogin> | null;
|
||||
if (
|
||||
typeof value?.nonce !== 'string' ||
|
||||
(value.purpose !== 'login' && value.purpose !== 'login_mfa') ||
|
||||
typeof value.return_path !== 'string' ||
|
||||
!value.return_path.startsWith('/') ||
|
||||
typeof value.created_at !== 'number'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const mfa = parseMfaChallenge(value.mfa);
|
||||
if ((value.purpose === 'login_mfa') !== (mfa !== null)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nonce: value.nonce,
|
||||
purpose: value.purpose,
|
||||
return_path: value.return_path,
|
||||
mfa,
|
||||
created_at: value.created_at,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredLogin(ceremonyId: string): StoredPasskeyBridgePageLogin | null {
|
||||
try {
|
||||
return parseStoredLogin(getProtectedSessionStorage()?.getItem(`${STORED_LOGIN_KEY_PREFIX}${ceremonyId}`) ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storePasskeyBridgePageLogin(ceremonyId: string, login: StoredPasskeyBridgePageLogin): void {
|
||||
const storage = getProtectedSessionStorage();
|
||||
if (storage === null) {
|
||||
throw new Error('Session storage is unavailable');
|
||||
}
|
||||
storage.setItem(`${STORED_LOGIN_KEY_PREFIX}${ceremonyId}`, JSON.stringify(login));
|
||||
}
|
||||
|
||||
export function readPasskeyBridgePageLoginReturnPath(ceremonyId: string): string | null {
|
||||
return readStoredLogin(ceremonyId)?.return_path ?? null;
|
||||
}
|
||||
|
||||
export function takePasskeyBridgePageLogin(ceremonyId: string): PasskeyBridgePageLogin | null {
|
||||
const stored = readStoredLogin(ceremonyId);
|
||||
try {
|
||||
getProtectedSessionStorage()?.removeItem(`${STORED_LOGIN_KEY_PREFIX}${ceremonyId}`);
|
||||
} catch {}
|
||||
return stored === null ? null : {nonce: stored.nonce, mfa: stored.mfa};
|
||||
}
|
||||
|
||||
export function prunePasskeyBridgePageLogins(now: number): void {
|
||||
try {
|
||||
const storage = getProtectedSessionStorage();
|
||||
if (storage === null) {
|
||||
return;
|
||||
}
|
||||
const expired: Array<string> = [];
|
||||
for (let index = 0; index < storage.length; index++) {
|
||||
const key = storage.key(index);
|
||||
if (key === null || !key.startsWith(STORED_LOGIN_KEY_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
const stored = parseStoredLogin(storage.getItem(key));
|
||||
if (stored === null || now - stored.created_at > STORED_LOGIN_MAX_AGE_MS) {
|
||||
expired.push(key);
|
||||
}
|
||||
}
|
||||
for (const key of expired) {
|
||||
storage.removeItem(key);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {randomBase64Url, sha256Hex} from '@app/features/app/domain_migration/DomainMigrationCrypto';
|
||||
import {
|
||||
type PasskeyBridgeLoginPurpose,
|
||||
type PasskeyBridgePageLogin,
|
||||
storePasskeyBridgePageLogin,
|
||||
} from '@app/features/auth/passkey_migration/PasskeyBridgeReturn';
|
||||
import type {MfaChallenge} from '@app/features/auth/state/AuthFlow';
|
||||
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import {PASSKEY_BRIDGE_CHANNEL} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import type {
|
||||
PasskeyBridgeFinishResponse,
|
||||
PasskeyBridgeLoginRedeemResponse,
|
||||
PasskeyBridgeLoginStartRequest,
|
||||
PasskeyBridgeStartResponse,
|
||||
PasskeyBridgeSudoRedeemResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
||||
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
|
||||
const NONCE_BYTES = 32;
|
||||
const LINK_CEREMONY_RESTART_MS = 9 * 60 * 1000;
|
||||
|
||||
interface LegacyCeremony {
|
||||
id: string;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
async function createNonce(): Promise<{nonce: string; nonceHash: string}> {
|
||||
const nonce = randomBase64Url(NONCE_BYTES);
|
||||
return {nonce, nonceHash: await sha256Hex(nonce)};
|
||||
}
|
||||
|
||||
export function isPasskeyCeremonyDismissed(error: unknown): boolean {
|
||||
if (Platform.isElectron) {
|
||||
return !(error instanceof HttpError);
|
||||
}
|
||||
return error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'AbortError');
|
||||
}
|
||||
|
||||
async function startLogin(request: PasskeyBridgeLoginStartRequest): Promise<PasskeyBridgeStartResponse> {
|
||||
const response = await http.post<PasskeyBridgeStartResponse>(Endpoints.AUTH_PASSKEY_BRIDGE, {body: request});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
async function startSudo(runner: 'page' | 'native', nonceHash: string): Promise<PasskeyBridgeStartResponse> {
|
||||
const response = await http.post<PasskeyBridgeStartResponse>(Endpoints.USER_PASSKEY_BRIDGE, {
|
||||
body: {runner, nonce_hash: nonceHash},
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
async function redeemLogin(
|
||||
ceremony: LegacyCeremony,
|
||||
completionCode: string,
|
||||
): Promise<PasskeyBridgeLoginRedeemResponse> {
|
||||
const response = await http.post<PasskeyBridgeLoginRedeemResponse>(
|
||||
Endpoints.AUTH_PASSKEY_BRIDGE_REDEEM(ceremony.id),
|
||||
{
|
||||
body: {nonce: ceremony.nonce, completion_code: completionCode},
|
||||
},
|
||||
);
|
||||
return response.body;
|
||||
}
|
||||
|
||||
async function redeemSudo(ceremony: LegacyCeremony, completionCode: string): Promise<PasskeyBridgeSudoRedeemResponse> {
|
||||
const response = await http.post<PasskeyBridgeSudoRedeemResponse>(Endpoints.USER_PASSKEY_BRIDGE_REDEEM(ceremony.id), {
|
||||
body: {nonce: ceremony.nonce, completion_code: completionCode},
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
function requireCompletionCode(finish: PasskeyBridgeFinishResponse): string {
|
||||
if (finish.completion_code === null) {
|
||||
throw new Error('Passkey bridge finished without a completion code');
|
||||
}
|
||||
return finish.completion_code;
|
||||
}
|
||||
|
||||
async function runNativeCeremony(ceremonyId: string): Promise<string> {
|
||||
const optionsResponse = await http.post<{options: PublicKeyCredentialRequestOptionsJSON}>(
|
||||
Endpoints.AUTH_PASSKEY_BRIDGE_OPTIONS(ceremonyId),
|
||||
);
|
||||
let credential: AuthenticationResponseJSON;
|
||||
try {
|
||||
credential = await WebAuthnUtils.performAuthentication(optionsResponse.body.options);
|
||||
} catch (error) {
|
||||
if (!isPasskeyCeremonyDismissed(error)) {
|
||||
throw error;
|
||||
}
|
||||
const cancelled = await http.post<PasskeyBridgeFinishResponse>(Endpoints.AUTH_PASSKEY_BRIDGE_CANCEL(ceremonyId));
|
||||
return requireCompletionCode(cancelled.body);
|
||||
}
|
||||
const completed = await http.post<PasskeyBridgeFinishResponse>(Endpoints.AUTH_PASSKEY_BRIDGE_COMPLETE(ceremonyId), {
|
||||
body: {response: credential},
|
||||
});
|
||||
return requireCompletionCode(completed.body);
|
||||
}
|
||||
|
||||
export async function startPasskeyBridgePageLogin(mfa: MfaChallenge | null, returnPath: string): Promise<void> {
|
||||
const purpose: PasskeyBridgeLoginPurpose = mfa === null ? 'login' : 'login_mfa';
|
||||
const {nonce, nonceHash} = await createNonce();
|
||||
const started = await startLogin({
|
||||
purpose,
|
||||
runner: 'page',
|
||||
...(mfa === null ? {} : {ticket: mfa.ticket}),
|
||||
nonce_hash: nonceHash,
|
||||
});
|
||||
if (started.bridge_url === null) {
|
||||
throw new Error('Passkey bridge did not return a page');
|
||||
}
|
||||
storePasskeyBridgePageLogin(started.ceremony_id, {
|
||||
nonce,
|
||||
purpose,
|
||||
return_path: returnPath,
|
||||
mfa,
|
||||
created_at: Date.now(),
|
||||
});
|
||||
window.location.assign(started.bridge_url);
|
||||
}
|
||||
|
||||
export function redeemPasskeyBridgePageLogin(
|
||||
ceremonyId: string,
|
||||
login: PasskeyBridgePageLogin,
|
||||
completionCode: string,
|
||||
): Promise<PasskeyBridgeLoginRedeemResponse> {
|
||||
return redeemLogin({id: ceremonyId, nonce: login.nonce}, completionCode);
|
||||
}
|
||||
|
||||
export async function runPasskeyBridgeNativeLogin(
|
||||
purpose: PasskeyBridgeLoginPurpose,
|
||||
ticket?: string,
|
||||
): Promise<PasskeyBridgeLoginRedeemResponse> {
|
||||
const {nonce, nonceHash} = await createNonce();
|
||||
const started = await startLogin({
|
||||
purpose,
|
||||
runner: 'native',
|
||||
...(ticket === undefined ? {} : {ticket}),
|
||||
nonce_hash: nonceHash,
|
||||
});
|
||||
const completionCode = await runNativeCeremony(started.ceremony_id);
|
||||
return redeemLogin({id: started.ceremony_id, nonce}, completionCode);
|
||||
}
|
||||
|
||||
export async function runPasskeyBridgeNativeSudo(): Promise<PasskeyBridgeSudoRedeemResponse> {
|
||||
const {nonce, nonceHash} = await createNonce();
|
||||
const started = await startSudo('native', nonceHash);
|
||||
const completionCode = await runNativeCeremony(started.ceremony_id);
|
||||
return redeemSudo({id: started.ceremony_id, nonce}, completionCode);
|
||||
}
|
||||
|
||||
interface PasskeyBridgeSudoLinkHandlers {
|
||||
onLink(url: string | null): void;
|
||||
onCompleted(sudoToken: string): void;
|
||||
onError(error: unknown): void;
|
||||
}
|
||||
|
||||
export class PasskeyBridgeSudoLink {
|
||||
private readonly channel = new BroadcastChannel(PASSKEY_BRIDGE_CHANNEL);
|
||||
private ceremony: LegacyCeremony | null = null;
|
||||
private restartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private generation = 0;
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly handlers: PasskeyBridgeSudoLinkHandlers) {
|
||||
this.channel.onmessage = this.handleMessage;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const generation = this.reset();
|
||||
this.handlers.onLink(null);
|
||||
try {
|
||||
const {nonce, nonceHash} = await createNonce();
|
||||
const started = await startSudo('page', nonceHash);
|
||||
if (generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
if (started.bridge_url === null) {
|
||||
throw new Error('Passkey bridge did not return a page');
|
||||
}
|
||||
this.ceremony = {id: started.ceremony_id, nonce};
|
||||
this.restartTimer = setTimeout(() => void this.start(), LINK_CEREMONY_RESTART_MS);
|
||||
this.handlers.onLink(started.bridge_url);
|
||||
} catch (error) {
|
||||
if (generation === this.generation) {
|
||||
this.handlers.onError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.reset();
|
||||
this.disposed = true;
|
||||
this.channel.close();
|
||||
}
|
||||
|
||||
private reset(): number {
|
||||
if (this.restartTimer !== null) {
|
||||
clearTimeout(this.restartTimer);
|
||||
this.restartTimer = null;
|
||||
}
|
||||
this.ceremony = null;
|
||||
this.generation += 1;
|
||||
return this.generation;
|
||||
}
|
||||
|
||||
private readonly handleMessage = (event: MessageEvent<unknown>): void => {
|
||||
const ceremony = this.ceremony;
|
||||
const data = event.data as {ceremony_id?: unknown; completion_code?: unknown} | null;
|
||||
if (
|
||||
this.disposed ||
|
||||
ceremony === null ||
|
||||
data?.ceremony_id !== ceremony.id ||
|
||||
typeof data.completion_code !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const generation = this.reset();
|
||||
redeemSudo(ceremony, data.completion_code).then(
|
||||
(result) => {
|
||||
if (generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
if (result.status === 'completed') {
|
||||
this.handlers.onCompleted(result.sudo_token);
|
||||
return;
|
||||
}
|
||||
void this.start();
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
this.handlers.onError(error);
|
||||
void this.start();
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readPasskeyLoginRoute, writePasskeyLoginRoute} from '@app/features/auth/passkey_migration/PasskeyLoginRoute';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('PasskeyLoginRoute', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('defaults to the page passkey when nothing is stored', () => {
|
||||
expect(readPasskeyLoginRoute()).toBe('native');
|
||||
});
|
||||
|
||||
it('remembers the previous address after it is chosen and forgets it again', () => {
|
||||
writePasskeyLoginRoute('legacy');
|
||||
expect(readPasskeyLoginRoute()).toBe('legacy');
|
||||
expect(window.localStorage.getItem('fluxer:passkey-login-route')).toBe('legacy');
|
||||
writePasskeyLoginRoute('native');
|
||||
expect(readPasskeyLoginRoute()).toBe('native');
|
||||
expect(window.localStorage.getItem('fluxer:passkey-login-route')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores unknown stored values', () => {
|
||||
window.localStorage.setItem('fluxer:passkey-login-route', 'something');
|
||||
expect(readPasskeyLoginRoute()).toBe('native');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getProtectedLocalStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
|
||||
const PASSKEY_LOGIN_ROUTE_KEY = 'fluxer:passkey-login-route';
|
||||
|
||||
export type PasskeyLoginRoute = 'legacy' | 'native';
|
||||
|
||||
export function readPasskeyLoginRoute(): PasskeyLoginRoute {
|
||||
try {
|
||||
return getProtectedLocalStorage()?.getItem(PASSKEY_LOGIN_ROUTE_KEY) === 'legacy' ? 'legacy' : 'native';
|
||||
} catch {
|
||||
return 'native';
|
||||
}
|
||||
}
|
||||
|
||||
export function writePasskeyLoginRoute(route: PasskeyLoginRoute): void {
|
||||
try {
|
||||
if (route === 'legacy') {
|
||||
getProtectedLocalStorage()?.setItem(PASSKEY_LOGIN_ROUTE_KEY, route);
|
||||
} else {
|
||||
getProtectedLocalStorage()?.removeItem(PASSKEY_LOGIN_ROUTE_KEY);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {readDomainMigrationDiscovery} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
||||
import {isPasskeyMigrationOrigin} from '@app/features/auth/passkey_migration/PasskeyMigrationOrigin';
|
||||
import {openPasskeyUpdateModal} from '@app/features/auth/passkey_migration/PasskeyUpdateModal';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import Modal from '@app/features/ui/state/Modal';
|
||||
import WebAuthnCredentials from '@app/features/user/state/WebAuthnCredentials';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import type {PasskeyMigrationResponse} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
|
||||
import {makeAutoObservable, runInAction, when} from 'mobx';
|
||||
|
||||
const logger = new Logger('PasskeyMigration');
|
||||
|
||||
export type PendingPasskeyMigration = NonNullable<PasskeyMigrationResponse['pending']>;
|
||||
|
||||
class PasskeyMigration {
|
||||
pending: PendingPasskeyMigration | null = null;
|
||||
private checkedUserId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
}
|
||||
|
||||
async check(): Promise<void> {
|
||||
try {
|
||||
const response = await http.get<PasskeyMigrationResponse>(Endpoints.USER_MFA_WEBAUTHN_MIGRATION);
|
||||
const pending = response.body.pending;
|
||||
runInAction(() => {
|
||||
this.pending = pending;
|
||||
});
|
||||
if (pending !== null) {
|
||||
openPasskeyUpdateModal(pending);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to check for a passkey to update', error);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pending = null;
|
||||
}
|
||||
|
||||
checkAfterSudo(sudoModalKey: string): void {
|
||||
const requester = Modal.modals.findLast((entry) => entry.key !== sudoModalKey && !entry.isBackground);
|
||||
if (requester === undefined) {
|
||||
void this.check();
|
||||
return;
|
||||
}
|
||||
when(
|
||||
() => !Modal.hasModal(requester.key),
|
||||
() => void this.check(),
|
||||
);
|
||||
}
|
||||
|
||||
handleGatewayReady(userId: string): void {
|
||||
if (
|
||||
this.checkedUserId === userId ||
|
||||
!isPasskeyMigrationOrigin() ||
|
||||
readDomainMigrationDiscovery()?.enabled !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.checkedUserId = userId;
|
||||
if (WebAuthnCredentials.credentials.some((credential) => credential.rp_id !== PASSKEY_MIGRATION_RP_ID)) {
|
||||
void this.check();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new PasskeyMigration();
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {rpIdMatchesPage} from '@app/features/auth/passkey_migration/PasskeyMigrationOrigin';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/app/domain_migration/DomainMigrationRollout', () => ({default: {enabled: false}}));
|
||||
|
||||
describe('rpIdMatchesPage', () => {
|
||||
it('matches the page host and its parent domains', () => {
|
||||
expect(rpIdMatchesPage('fluxer.com', 'fluxer.com')).toBe(true);
|
||||
expect(rpIdMatchesPage('fluxer.com', 'canary.fluxer.com')).toBe(true);
|
||||
expect(rpIdMatchesPage('fluxer.app', 'web.canary.fluxer.app')).toBe(true);
|
||||
expect(rpIdMatchesPage('Fluxer.COM', 'fluxer.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects other domains', () => {
|
||||
expect(rpIdMatchesPage('fluxer.app', 'fluxer.com')).toBe(false);
|
||||
expect(rpIdMatchesPage('fluxer.com', 'notfluxer.com')).toBe(false);
|
||||
expect(rpIdMatchesPage('canary.fluxer.com', 'fluxer.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {resolveDomainMigrationSide} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
|
||||
export function isPasskeyMigrationOrigin(): boolean {
|
||||
return resolveDomainMigrationSide(window.location.origin)?.role === 'target';
|
||||
}
|
||||
|
||||
export function rpIdMatchesPage(rpId: string, hostname: string = window.location.hostname): boolean {
|
||||
const host = hostname.toLowerCase();
|
||||
const normalizedRpId = rpId.toLowerCase();
|
||||
return host === normalizedRpId || host.endsWith(`.${normalizedRpId}`);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {writePasskeyLoginRoute} from '@app/features/auth/passkey_migration/PasskeyLoginRoute';
|
||||
import PasskeyMigration, {type PendingPasskeyMigration} from '@app/features/auth/passkey_migration/PasskeyMigration';
|
||||
import styles from '@app/features/auth/passkey_migration/PasskeyUpdateModal.module.css';
|
||||
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type {PublicKeyCredentialCreationOptionsJSON} from '@simplewebauthn/browser';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
const PASSKEY_UPDATE_MODAL_KEY = 'passkey-update-modal';
|
||||
const logger = new Logger('PasskeyUpdateModal');
|
||||
|
||||
const UPDATE_YOUR_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Update your passkey',
|
||||
comment: 'Title of the modal that asks the user to save an updated passkey after signing in with an older one.',
|
||||
});
|
||||
const UPDATE_PASSKEY_BODY_DESCRIPTOR = msg({
|
||||
message: 'Your device will ask you to save an updated passkey for {name}. It replaces the old one.',
|
||||
comment:
|
||||
'Body of the modal that asks the user to save an updated passkey. name is the name the user gave the passkey. Keep plain.',
|
||||
});
|
||||
const UPDATE_PASSKEY_CROSS_DEVICE_BODY_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Your device will ask you to save an updated passkey for {name}. Use the same phone or security key you just used.',
|
||||
comment:
|
||||
'Body of the modal that asks the user to save an updated passkey when they signed in with a phone or security key. name is the name the user gave the passkey. Keep plain.',
|
||||
});
|
||||
const UPDATE_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Update passkey',
|
||||
comment: 'Primary button in the modal that saves an updated passkey.',
|
||||
});
|
||||
const NOT_NOW_DESCRIPTOR = msg({
|
||||
message: 'Not now',
|
||||
comment: 'Secondary button that closes the passkey update modal after an attempt failed.',
|
||||
});
|
||||
const COULDN_T_UPDATE_YOUR_PASSKEY_DESCRIPTOR = msg({
|
||||
message: "Couldn't update your passkey. Try again.",
|
||||
comment: 'Error shown in the passkey update modal when saving the updated passkey failed. Keep plain.',
|
||||
});
|
||||
const PASSKEY_UPDATED_DESCRIPTOR = msg({
|
||||
message: 'Passkey updated',
|
||||
comment: 'Toast shown after the user saved an updated passkey.',
|
||||
});
|
||||
|
||||
const isAlreadyRegisteredError = (error: unknown): boolean =>
|
||||
error instanceof Error && error.name === 'InvalidStateError';
|
||||
|
||||
const PasskeyUpdateModal = observer(({pending}: {pending: PendingPasskeyMigration}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [options, setOptions] = useState<PublicKeyCredentialCreationOptionsJSON | null>(null);
|
||||
const [optionsRequest, setOptionsRequest] = useState(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setOptions(null);
|
||||
http
|
||||
.post<PublicKeyCredentialCreationOptionsJSON>(Endpoints.USER_MFA_WEBAUTHN_MIGRATION_REGISTRATION_OPTIONS)
|
||||
.then((response) => {
|
||||
if (current) {
|
||||
setOptions(response.body);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (current) {
|
||||
logger.error('Failed to get registration options for the passkey update', error);
|
||||
setFailed(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [optionsRequest]);
|
||||
const close = useCallback(() => {
|
||||
PasskeyMigration.clear();
|
||||
ModalCommands.popWithKey(PASSKEY_UPDATE_MODAL_KEY);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
if (failed && !submitting) {
|
||||
close();
|
||||
}
|
||||
}, [close, failed, submitting]);
|
||||
const handleUpdate = async () => {
|
||||
if (options === null) {
|
||||
setOptionsRequest((request) => request + 1);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const credential = await WebAuthnUtils.performRegistration(options);
|
||||
await http.post(Endpoints.USER_MFA_WEBAUTHN_MIGRATION, {
|
||||
body: {response: credential, challenge: options.challenge},
|
||||
});
|
||||
close();
|
||||
ToastCommands.success(i18n._(PASSKEY_UPDATED_DESCRIPTOR));
|
||||
} catch (error) {
|
||||
if (isAlreadyRegisteredError(error)) {
|
||||
writePasskeyLoginRoute('native');
|
||||
close();
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to update passkey', error);
|
||||
setFailed(true);
|
||||
setOptionsRequest((request) => request + 1);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal.Root size="small" centered onClose={handleClose} data-flx="auth.passkey-update-modal.modal-root">
|
||||
<Modal.Header
|
||||
title={i18n._(UPDATE_YOUR_PASSKEY_DESCRIPTOR)}
|
||||
hideCloseButton={!failed}
|
||||
onClose={handleClose}
|
||||
data-flx="auth.passkey-update-modal.modal-header"
|
||||
/>
|
||||
<Modal.Content data-flx="auth.passkey-update-modal.modal-content">
|
||||
<Modal.ContentLayout data-flx="auth.passkey-update-modal.modal-content-layout">
|
||||
<Modal.Description data-flx="auth.passkey-update-modal.description">
|
||||
{i18n._(
|
||||
pending.cross_device ? UPDATE_PASSKEY_CROSS_DEVICE_BODY_DESCRIPTOR : UPDATE_PASSKEY_BODY_DESCRIPTOR,
|
||||
{name: pending.name},
|
||||
)}
|
||||
</Modal.Description>
|
||||
{failed && (
|
||||
<p className={styles.error} role="alert" data-flx="auth.passkey-update-modal.error">
|
||||
{i18n._(COULDN_T_UPDATE_YOUR_PASSKEY_DESCRIPTOR)}
|
||||
</p>
|
||||
)}
|
||||
</Modal.ContentLayout>
|
||||
</Modal.Content>
|
||||
<Modal.Footer data-flx="auth.passkey-update-modal.modal-footer">
|
||||
{failed && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={close}
|
||||
disabled={submitting}
|
||||
data-flx="auth.passkey-update-modal.button.not-now"
|
||||
>
|
||||
{i18n._(NOT_NOW_DESCRIPTOR)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
submitting={submitting || (options === null && !failed)}
|
||||
autoFocus
|
||||
data-flx="auth.passkey-update-modal.button.update"
|
||||
>
|
||||
{i18n._(UPDATE_PASSKEY_DESCRIPTOR)}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export function openPasskeyUpdateModal(pending: PendingPasskeyMigration): void {
|
||||
ModalCommands.pushWithKey(
|
||||
modal(() => <PasskeyUpdateModal pending={pending} data-flx="auth.passkey-update-modal" />),
|
||||
PASSKEY_UPDATE_MODAL_KEY,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
prunePasskeyBridgePageLogins,
|
||||
readPasskeyBridgeReturn,
|
||||
takePasskeyBridgePageLogin,
|
||||
} from '@app/features/auth/passkey_migration/PasskeyBridgeReturn';
|
||||
import {redeemPasskeyBridgePageLogin} from '@app/features/auth/passkey_migration/PasskeyLegacyCeremony';
|
||||
import {writePasskeyLoginRoute} from '@app/features/auth/passkey_migration/PasskeyLoginRoute';
|
||||
import {isPasskeyMigrationOrigin} from '@app/features/auth/passkey_migration/PasskeyMigrationOrigin';
|
||||
import {type LoginSuccessPayload, type MfaChallenge, toLoginSuccessPayload} from '@app/features/auth/state/AuthFlow';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
const logger = new Logger('usePasskeyBridgeReturn');
|
||||
|
||||
interface PasskeyBridgeReturnOptions {
|
||||
redirectPath?: string;
|
||||
onLoginSuccess: (payload: LoginSuccessPayload) => Promise<void> | void;
|
||||
onRequireMfa: (challenge: MfaChallenge) => void;
|
||||
onFailure: () => void;
|
||||
}
|
||||
|
||||
export function usePasskeyBridgeReturn(options: PasskeyBridgeReturnOptions): boolean {
|
||||
const [isRedeeming, setIsRedeeming] = useState(false);
|
||||
const handledRef = useRef(false);
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
useEffect(() => {
|
||||
if (handledRef.current || !isPasskeyMigrationOrigin()) {
|
||||
return;
|
||||
}
|
||||
handledRef.current = true;
|
||||
prunePasskeyBridgePageLogins(Date.now());
|
||||
const bridgeReturn = readPasskeyBridgeReturn(window.location.hash);
|
||||
if (bridgeReturn === null) {
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`);
|
||||
const login = takePasskeyBridgePageLogin(bridgeReturn.ceremonyId);
|
||||
if (login === null) {
|
||||
optionsRef.current.onFailure();
|
||||
return;
|
||||
}
|
||||
const restoreMfa = () => {
|
||||
if (login.mfa !== null) {
|
||||
optionsRef.current.onRequireMfa(login.mfa);
|
||||
}
|
||||
};
|
||||
setIsRedeeming(true);
|
||||
redeemPasskeyBridgePageLogin(bridgeReturn.ceremonyId, login, bridgeReturn.completionCode)
|
||||
.then(async (result) => {
|
||||
if (result.status === 'cancelled') {
|
||||
writePasskeyLoginRoute('native');
|
||||
restoreMfa();
|
||||
return;
|
||||
}
|
||||
await optionsRef.current.onLoginSuccess(toLoginSuccessPayload(result));
|
||||
if (optionsRef.current.redirectPath) {
|
||||
RouterUtils.replaceWith(optionsRef.current.redirectPath);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error('Passkey sign-in on the previous address failed', error);
|
||||
restoreMfa();
|
||||
optionsRef.current.onFailure();
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRedeeming(false);
|
||||
});
|
||||
}, []);
|
||||
return isRedeeming;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export type LoginResult =
|
||||
| {type: 'ip_authorization'; challenge: IpAuthorizationChallenge}
|
||||
| {type: 'suspended'; banViewToken: string};
|
||||
|
||||
function toLoginSuccessPayload(response: AuthenticationCommands.AuthTokenResponse): LoginSuccessPayload {
|
||||
export function toLoginSuccessPayload(response: AuthenticationCommands.AuthTokenResponse): LoginSuccessPayload {
|
||||
const userData = AuthenticationCommands.authResponseUserToUserData(response.user);
|
||||
return {
|
||||
token: response.token,
|
||||
|
||||
@@ -52,7 +52,7 @@ export function isAbortError(error: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const SUDO_MODAL_KEY = 'sudo-verification-modal';
|
||||
export const SUDO_MODAL_KEY = 'sudo-verification-modal';
|
||||
|
||||
export interface AvailableMethods {
|
||||
password: boolean;
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
isPasskeyBridgeReady,
|
||||
PASSKEY_BRIDGE_PATH,
|
||||
PASSKEY_BRIDGE_REQUEST_TYPE,
|
||||
PASSKEY_BRIDGE_TIMEOUT_MS,
|
||||
PASSKEY_BRIDGE_VERSION,
|
||||
type PasskeyBridgeKind,
|
||||
type PasskeyBridgeOptionsMap,
|
||||
type PasskeyBridgeRequest,
|
||||
type PasskeyBridgeResponseMap,
|
||||
parsePasskeyBridgeResult,
|
||||
resolvePasskeyBridgeLegacyOrigin,
|
||||
} from '@app/features/auth/utils/PasskeyBridgeProtocol';
|
||||
import {PasskeyDomainUnsupportedError} from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const PASSKEY_BRIDGE_POPUP_FEATURES = 'popup,width=460,height=620';
|
||||
const PASSKEY_BRIDGE_CLOSED_POLL_MS = 500;
|
||||
const SUGGEST_BRIDGE_ERROR_NAMES: ReadonlySet<string> = new Set(['NotAllowedError', 'SecurityError']);
|
||||
|
||||
const PASSKEY_BRIDGE_POPUP_BLOCKED_DESCRIPTOR = msg({
|
||||
message: 'Your browser blocked the passkey pop-up window. Allow pop-ups for this site and try again.',
|
||||
comment: 'Error shown when the browser blocks the pop-up window used for password manager passkeys.',
|
||||
});
|
||||
const PASSKEY_BRIDGE_FAILED_DESCRIPTOR = msg({
|
||||
message: "Couldn't use your passkey in the pop-up window. Try again.",
|
||||
comment: 'Error shown when the pop-up window used for password manager passkeys does not finish.',
|
||||
});
|
||||
|
||||
export class PasskeyBridgeError extends Error {
|
||||
constructor(name: string, message: string) {
|
||||
super(message);
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPasskeyBridgeAvailable(): boolean {
|
||||
return !Platform.isElectron && resolvePasskeyBridgeLegacyOrigin(window.location.origin) !== null;
|
||||
}
|
||||
|
||||
export function shouldSuggestPasskeyBridge(error: unknown): boolean {
|
||||
if (!isPasskeyBridgeAvailable() || error instanceof PasskeyBridgeError) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
error instanceof PasskeyDomainUnsupportedError ||
|
||||
(error instanceof Error && SUGGEST_BRIDGE_ERROR_NAMES.has(error.name))
|
||||
);
|
||||
}
|
||||
|
||||
function isPasskeyBridgeDismissal(error: unknown): boolean {
|
||||
return error instanceof PasskeyBridgeError && (error.name === 'AbortError' || error.name === 'NotAllowedError');
|
||||
}
|
||||
|
||||
export function describePasskeyBridgeFailure(error: unknown): MessageDescriptor | null {
|
||||
if (isPasskeyBridgeDismissal(error)) {
|
||||
return null;
|
||||
}
|
||||
if (error instanceof PasskeyBridgeError && error.name === 'PopupBlockedError') {
|
||||
return PASSKEY_BRIDGE_POPUP_BLOCKED_DESCRIPTOR;
|
||||
}
|
||||
return PASSKEY_BRIDGE_FAILED_DESCRIPTOR;
|
||||
}
|
||||
|
||||
export function runPasskeyViaBridge<K extends PasskeyBridgeKind>(
|
||||
kind: K,
|
||||
options: Promise<PasskeyBridgeOptionsMap[K]>,
|
||||
): Promise<PasskeyBridgeResponseMap[K]> {
|
||||
const legacyOrigin = isPasskeyBridgeAvailable() ? resolvePasskeyBridgeLegacyOrigin(window.location.origin) : null;
|
||||
const popup =
|
||||
legacyOrigin === null
|
||||
? null
|
||||
: window.open(`${legacyOrigin}${PASSKEY_BRIDGE_PATH}`, '_blank', PASSKEY_BRIDGE_POPUP_FEATURES);
|
||||
if (legacyOrigin === null || popup === null) {
|
||||
options.catch(() => {});
|
||||
const name = legacyOrigin === null ? 'NotSupportedError' : 'PopupBlockedError';
|
||||
return Promise.reject(new PasskeyBridgeError(name, 'The passkey window could not be opened'));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
let ready = false;
|
||||
let settled = false;
|
||||
let requestSent = false;
|
||||
let closedSeen = false;
|
||||
let resolvedOptions: PasskeyBridgeOptionsMap[K] | null = null;
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
window.clearInterval(closedPoll);
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
const fail = (error: unknown) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (!popup.closed) {
|
||||
popup.close();
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
const sendRequest = () => {
|
||||
if (!ready || resolvedOptions === null || requestSent || settled) {
|
||||
return;
|
||||
}
|
||||
requestSent = true;
|
||||
const request = {
|
||||
type: PASSKEY_BRIDGE_REQUEST_TYPE,
|
||||
v: PASSKEY_BRIDGE_VERSION,
|
||||
id,
|
||||
kind,
|
||||
options: resolvedOptions,
|
||||
} as PasskeyBridgeRequest;
|
||||
popup.postMessage(request, legacyOrigin);
|
||||
popup.focus();
|
||||
};
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (settled || event.origin !== legacyOrigin || event.source !== popup) {
|
||||
return;
|
||||
}
|
||||
if (isPasskeyBridgeReady(event.data)) {
|
||||
ready = true;
|
||||
sendRequest();
|
||||
return;
|
||||
}
|
||||
const result = parsePasskeyBridgeResult(event.data, id);
|
||||
if (result === null) {
|
||||
return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
fail(new PasskeyBridgeError(result.error.name, result.error.message));
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(result.response as PasskeyBridgeResponseMap[K]);
|
||||
};
|
||||
const closedPoll = window.setInterval(() => {
|
||||
if (!popup.closed) {
|
||||
return;
|
||||
}
|
||||
if (closedSeen) {
|
||||
fail(new PasskeyBridgeError('AbortError', 'The passkey window was closed'));
|
||||
}
|
||||
closedSeen = true;
|
||||
}, PASSKEY_BRIDGE_CLOSED_POLL_MS);
|
||||
const timeout = window.setTimeout(() => {
|
||||
fail(new PasskeyBridgeError('TimeoutError', 'The passkey window timed out'));
|
||||
}, PASSKEY_BRIDGE_TIMEOUT_MS);
|
||||
window.addEventListener('message', handleMessage);
|
||||
options.then(
|
||||
(value) => {
|
||||
resolvedOptions = value;
|
||||
sendRequest();
|
||||
},
|
||||
(error: unknown) => fail(error),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
PASSKEY_BRIDGE_REQUEST_TYPE,
|
||||
PASSKEY_BRIDGE_RESULT_TYPE,
|
||||
parsePasskeyBridgeRequest,
|
||||
parsePasskeyBridgeResult,
|
||||
resolvePasskeyBridgeLegacyOrigin,
|
||||
resolvePasskeyBridgeOpenerOrigin,
|
||||
} from '@app/features/auth/utils/PasskeyBridgeProtocol';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function authenticateRequest(options: Record<string, unknown>): Record<string, unknown> {
|
||||
return {type: PASSKEY_BRIDGE_REQUEST_TYPE, v: 1, id: 'req', kind: 'authenticate', options};
|
||||
}
|
||||
|
||||
function registerRequest(options: Record<string, unknown>): Record<string, unknown> {
|
||||
return {type: PASSKEY_BRIDGE_REQUEST_TYPE, v: 1, id: 'req', kind: 'register', options};
|
||||
}
|
||||
|
||||
describe('resolvePasskeyBridgeOpenerOrigin', () => {
|
||||
it('pairs each official legacy origin with its target only', () => {
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://web.fluxer.app', '/passkey-bridge')).toBe('https://fluxer.com');
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://web.canary.fluxer.app', '/passkey-bridge')).toBe(
|
||||
'https://canary.fluxer.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores other origins and paths', () => {
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://fluxer.com', '/passkey-bridge')).toBeNull();
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://fluxer.app', '/passkey-bridge')).toBeNull();
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://chat.example.com', '/passkey-bridge')).toBeNull();
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://web.fluxer.app', '/passkey-bridge/')).toBeNull();
|
||||
expect(resolvePasskeyBridgeOpenerOrigin('https://web.fluxer.app', '/login')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePasskeyBridgeLegacyOrigin', () => {
|
||||
it('resolves only from official target origins', () => {
|
||||
expect(resolvePasskeyBridgeLegacyOrigin('https://fluxer.com')).toBe('https://web.fluxer.app');
|
||||
expect(resolvePasskeyBridgeLegacyOrigin('https://canary.fluxer.com')).toBe('https://web.canary.fluxer.app');
|
||||
expect(resolvePasskeyBridgeLegacyOrigin('https://web.fluxer.app')).toBeNull();
|
||||
expect(resolvePasskeyBridgeLegacyOrigin('https://chat.example.com')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePasskeyBridgeRequest', () => {
|
||||
it('accepts requests for the fluxer.app relying party', () => {
|
||||
expect(parsePasskeyBridgeRequest(authenticateRequest({challenge: 'c', rpId: 'fluxer.app'}))).not.toBeNull();
|
||||
expect(
|
||||
parsePasskeyBridgeRequest(registerRequest({challenge: 'c', rp: {id: 'fluxer.app', name: 'Fluxer'}, user: {}})),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects any other relying party', () => {
|
||||
for (const rpId of ['evil.example', 'web.fluxer.app', 'fluxer.com', 'app', undefined]) {
|
||||
expect(parsePasskeyBridgeRequest(authenticateRequest({challenge: 'c', rpId}))).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest(registerRequest({challenge: 'c', rp: {id: rpId}, user: {}}))).toBeNull();
|
||||
}
|
||||
expect(parsePasskeyBridgeRequest(registerRequest({challenge: 'c', rpId: 'fluxer.app', user: {}}))).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest(authenticateRequest({challenge: 'c', rp: {id: 'fluxer.app'}}))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects malformed envelopes', () => {
|
||||
const options = {challenge: 'c', rpId: 'fluxer.app'};
|
||||
expect(parsePasskeyBridgeRequest({...authenticateRequest(options), v: 2})).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest({...authenticateRequest(options), type: 'other'})).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest({...authenticateRequest(options), id: ''})).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest({...authenticateRequest(options), kind: 'sign'})).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest(authenticateRequest({rpId: 'fluxer.app'}))).toBeNull();
|
||||
expect(parsePasskeyBridgeRequest('request')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePasskeyBridgeResult', () => {
|
||||
const response = {id: 'cred', rawId: 'cred', response: {}, type: 'public-key', clientExtensionResults: {}};
|
||||
|
||||
it('accepts only the matching request id', () => {
|
||||
const result = {type: PASSKEY_BRIDGE_RESULT_TYPE, v: 1, id: 'req', ok: true, response};
|
||||
expect(parsePasskeyBridgeResult(result, 'req')).not.toBeNull();
|
||||
expect(parsePasskeyBridgeResult(result, 'other')).toBeNull();
|
||||
});
|
||||
|
||||
it('normalises failures and rejects malformed successes', () => {
|
||||
expect(
|
||||
parsePasskeyBridgeResult({type: PASSKEY_BRIDGE_RESULT_TYPE, v: 1, id: 'req', ok: false, error: {}}, 'req'),
|
||||
).toMatchObject({ok: false, error: {name: 'UnknownError', message: ''}});
|
||||
expect(
|
||||
parsePasskeyBridgeResult({type: PASSKEY_BRIDGE_RESULT_TYPE, v: 1, id: 'req', ok: true, response: {}}, 'req'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
DOMAIN_MIGRATION_SOURCE_TO_TARGET,
|
||||
DOMAIN_MIGRATION_TARGET_TO_SOURCE,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCore';
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/browser';
|
||||
|
||||
export const PASSKEY_BRIDGE_PATH = '/passkey-bridge';
|
||||
export const PASSKEY_BRIDGE_VERSION = 1;
|
||||
export const PASSKEY_BRIDGE_RP_ID = 'fluxer.app';
|
||||
export const PASSKEY_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
export const PASSKEY_BRIDGE_READY_TYPE = 'fluxer:passkey-bridge:ready';
|
||||
export const PASSKEY_BRIDGE_REQUEST_TYPE = 'fluxer:passkey-bridge:request';
|
||||
export const PASSKEY_BRIDGE_RESULT_TYPE = 'fluxer:passkey-bridge:result';
|
||||
|
||||
export type PasskeyBridgeKind = 'authenticate' | 'register';
|
||||
|
||||
export interface PasskeyBridgeOptionsMap {
|
||||
authenticate: PublicKeyCredentialRequestOptionsJSON;
|
||||
register: PublicKeyCredentialCreationOptionsJSON;
|
||||
}
|
||||
|
||||
export interface PasskeyBridgeResponseMap {
|
||||
authenticate: AuthenticationResponseJSON;
|
||||
register: RegistrationResponseJSON;
|
||||
}
|
||||
|
||||
export type PasskeyBridgeRequest = {
|
||||
[K in PasskeyBridgeKind]: {
|
||||
type: typeof PASSKEY_BRIDGE_REQUEST_TYPE;
|
||||
v: typeof PASSKEY_BRIDGE_VERSION;
|
||||
id: string;
|
||||
kind: K;
|
||||
options: PasskeyBridgeOptionsMap[K];
|
||||
};
|
||||
}[PasskeyBridgeKind];
|
||||
|
||||
export interface PasskeyBridgeErrorPayload {
|
||||
name: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type PasskeyBridgeResult =
|
||||
| {
|
||||
type: typeof PASSKEY_BRIDGE_RESULT_TYPE;
|
||||
v: typeof PASSKEY_BRIDGE_VERSION;
|
||||
id: string;
|
||||
ok: true;
|
||||
response: PasskeyBridgeResponseMap[PasskeyBridgeKind];
|
||||
}
|
||||
| {
|
||||
type: typeof PASSKEY_BRIDGE_RESULT_TYPE;
|
||||
v: typeof PASSKEY_BRIDGE_VERSION;
|
||||
id: string;
|
||||
ok: false;
|
||||
error: PasskeyBridgeErrorPayload;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
export function resolvePasskeyBridgeOpenerOrigin(origin: string, pathname: string): string | null {
|
||||
if (pathname !== PASSKEY_BRIDGE_PATH) {
|
||||
return null;
|
||||
}
|
||||
return DOMAIN_MIGRATION_SOURCE_TO_TARGET[origin] ?? null;
|
||||
}
|
||||
|
||||
export function resolvePasskeyBridgeLegacyOrigin(origin: string): string | null {
|
||||
return DOMAIN_MIGRATION_TARGET_TO_SOURCE[origin] ?? null;
|
||||
}
|
||||
|
||||
export function isPasskeyBridgeReady(data: unknown): boolean {
|
||||
return isRecord(data) && data.type === PASSKEY_BRIDGE_READY_TYPE && data.v === PASSKEY_BRIDGE_VERSION;
|
||||
}
|
||||
|
||||
export function readPasskeyBridgeRequestId(data: unknown): string | null {
|
||||
return isRecord(data) && isNonEmptyString(data.id) ? data.id : null;
|
||||
}
|
||||
|
||||
function readRequestRpId(kind: unknown, options: Record<string, unknown>): unknown {
|
||||
if (kind === 'authenticate') {
|
||||
return options.rpId;
|
||||
}
|
||||
if (kind === 'register') {
|
||||
return isRecord(options.rp) ? options.rp.id : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parsePasskeyBridgeRequest(data: unknown): PasskeyBridgeRequest | null {
|
||||
if (
|
||||
!isRecord(data) ||
|
||||
data.type !== PASSKEY_BRIDGE_REQUEST_TYPE ||
|
||||
data.v !== PASSKEY_BRIDGE_VERSION ||
|
||||
!isNonEmptyString(data.id) ||
|
||||
!isRecord(data.options) ||
|
||||
!isNonEmptyString(data.options.challenge)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (readRequestRpId(data.kind, data.options) !== PASSKEY_BRIDGE_RP_ID) {
|
||||
return null;
|
||||
}
|
||||
if (data.kind === 'register' && !isRecord(data.options.user)) {
|
||||
return null;
|
||||
}
|
||||
return data as unknown as PasskeyBridgeRequest;
|
||||
}
|
||||
|
||||
export function parsePasskeyBridgeResult(data: unknown, id: string): PasskeyBridgeResult | null {
|
||||
if (
|
||||
!isRecord(data) ||
|
||||
data.type !== PASSKEY_BRIDGE_RESULT_TYPE ||
|
||||
data.v !== PASSKEY_BRIDGE_VERSION ||
|
||||
data.id !== id
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (data.ok === true) {
|
||||
return isRecord(data.response) && isNonEmptyString(data.response.id) && isRecord(data.response.response)
|
||||
? (data as unknown as PasskeyBridgeResult)
|
||||
: null;
|
||||
}
|
||||
if (data.ok === false && isRecord(data.error)) {
|
||||
return {
|
||||
type: PASSKEY_BRIDGE_RESULT_TYPE,
|
||||
v: PASSKEY_BRIDGE_VERSION,
|
||||
id,
|
||||
ok: false,
|
||||
error: {
|
||||
name: typeof data.error.name === 'string' ? data.error.name : 'UnknownError',
|
||||
message: typeof data.error.message === 'string' ? data.error.message : '',
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toPasskeyBridgeErrorPayload(error: unknown): PasskeyBridgeErrorPayload {
|
||||
if (error instanceof Error) {
|
||||
return {name: error.name, message: error.message};
|
||||
}
|
||||
return {name: 'UnknownError', message: String(error)};
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {promptForSecurityKeyPin} from '@app/features/auth/components/modals/PasskeyPinModal';
|
||||
import {writePasskeyLoginRoute} from '@app/features/auth/passkey_migration/PasskeyLoginRoute';
|
||||
import {parsePasskeyPinFailure} from '@app/features/auth/utils/PasskeyPinErrors';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {
|
||||
type AuthenticationResponseJSON,
|
||||
browserSupportsWebAuthn,
|
||||
@@ -15,47 +16,6 @@ import {
|
||||
startRegistration,
|
||||
} from '@simplewebauthn/browser';
|
||||
|
||||
export const PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Your browser does not support passkeys on this domain. Update your browser, or sign in with your password and two-factor code.',
|
||||
comment:
|
||||
'Error shown when a passkey prompt fails because the browser cannot use the passkey on this web address. Keep plain.',
|
||||
});
|
||||
const RP_MISMATCH_MESSAGE_PATTERN = /relying party|\brp ?id\b|\bdomain\b|\borigin\b/i;
|
||||
|
||||
export class PasskeyDomainUnsupportedError extends Error {
|
||||
constructor() {
|
||||
super('Passkeys are not supported on this domain in this browser');
|
||||
this.name = 'PasskeyDomainUnsupportedError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRelatedOriginFailure(error: unknown, rpId: string | undefined): boolean {
|
||||
if (!rpId || !(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const hostname = window.location.hostname.toLowerCase();
|
||||
const normalizedRpId = rpId.toLowerCase();
|
||||
if (hostname === normalizedRpId || hostname.endsWith(`.${normalizedRpId}`)) {
|
||||
return false;
|
||||
}
|
||||
if (error.name === 'SecurityError') {
|
||||
return true;
|
||||
}
|
||||
return error.name === 'NotAllowedError' && RP_MISMATCH_MESSAGE_PATTERN.test(error.message);
|
||||
}
|
||||
|
||||
async function runBrowserCeremony<T>(rpId: string | undefined, run: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (isRelatedOriginFailure(error, rpId)) {
|
||||
throw new PasskeyDomainUnsupportedError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runNativeCeremonyWithPinSupport<T>(run: (requestContext?: {pin?: string}) => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await run();
|
||||
@@ -67,6 +27,14 @@ async function runNativeCeremonyWithPinSupport<T>(run: (requestContext?: {pin?:
|
||||
return promptForSecurityKeyPin((pin) => run({pin}));
|
||||
}
|
||||
|
||||
async function rememberMigratedPasskeyUse<T>(rpId: string | undefined, ceremony: Promise<T>): Promise<T> {
|
||||
const result = await ceremony;
|
||||
if (rpId === PASSKEY_MIGRATION_RP_ID) {
|
||||
writePasskeyLoginRoute('native');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function assertWebAuthnSupported(): Promise<void> {
|
||||
if (Platform.isElectron) {
|
||||
const electronApi = getElectronAPI();
|
||||
@@ -93,10 +61,13 @@ export async function performRegistration(
|
||||
const nativeSupported = electronApi && (await electronApi.passkeyIsSupported?.());
|
||||
const passkeyRegister = electronApi?.passkeyRegister;
|
||||
if (nativeSupported && passkeyRegister) {
|
||||
return runNativeCeremonyWithPinSupport((requestContext) => passkeyRegister(options, requestContext));
|
||||
return rememberMigratedPasskeyUse(
|
||||
options.rp.id,
|
||||
runNativeCeremonyWithPinSupport((requestContext) => passkeyRegister(options, requestContext)),
|
||||
);
|
||||
}
|
||||
}
|
||||
return await runBrowserCeremony(options.rp.id, () => startRegistration({optionsJSON: options}));
|
||||
return rememberMigratedPasskeyUse(options.rp.id, startRegistration({optionsJSON: options}));
|
||||
}
|
||||
|
||||
export async function performAuthentication(
|
||||
@@ -108,8 +79,11 @@ export async function performAuthentication(
|
||||
const nativeSupported = electronApi && (await electronApi.passkeyIsSupported?.());
|
||||
const passkeyAuthenticate = electronApi?.passkeyAuthenticate;
|
||||
if (nativeSupported && passkeyAuthenticate) {
|
||||
return runNativeCeremonyWithPinSupport((requestContext) => passkeyAuthenticate(options, requestContext));
|
||||
return rememberMigratedPasskeyUse(
|
||||
options.rpId,
|
||||
runNativeCeremonyWithPinSupport((requestContext) => passkeyAuthenticate(options, requestContext)),
|
||||
);
|
||||
}
|
||||
}
|
||||
return await runBrowserCeremony(options.rpId, () => startAuthentication({optionsJSON: options}));
|
||||
return rememberMigratedPasskeyUse(options.rpId, startAuthentication({optionsJSON: options}));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {startDomainMigrationTrigger} from '@app/features/app/domain_migration/DomainMigrationTrigger';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import PasskeyMigration from '@app/features/auth/passkey_migration/PasskeyMigration';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import accountStorage from '@app/features/auth/state/AccountStorage';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
@@ -186,4 +187,5 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
|
||||
context.setReady();
|
||||
Messages.handleGatewayReady();
|
||||
startDomainMigrationTrigger();
|
||||
PasskeyMigration.handleGatewayReady(data.user.id);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1419,6 +1419,12 @@
|
||||
{
|
||||
"msgid": "About me is too long"
|
||||
},
|
||||
{
|
||||
"msgid": "Accept the push relay supplemental privacy notice"
|
||||
},
|
||||
{
|
||||
"msgid": "Accepted"
|
||||
},
|
||||
{
|
||||
"msgid": "Add a Klipy API key to enable GIF search at runtime."
|
||||
},
|
||||
@@ -1545,6 +1551,9 @@
|
||||
{
|
||||
"msgid": "Automatic ({codec})"
|
||||
},
|
||||
{
|
||||
"msgid": "Back to {productName}"
|
||||
},
|
||||
{
|
||||
"msgid": "Backup code"
|
||||
},
|
||||
@@ -1716,6 +1725,9 @@
|
||||
{
|
||||
"msgid": "Changed the voice region from {oldRegion} to {newRegion}."
|
||||
},
|
||||
{
|
||||
"msgid": "Checking your browser. This takes a few seconds."
|
||||
},
|
||||
{
|
||||
"msgid": "Choices"
|
||||
},
|
||||
@@ -1788,12 +1800,6 @@
|
||||
{
|
||||
"msgid": "Contact the administrators of this instance for help."
|
||||
},
|
||||
{
|
||||
"msgid": "Continue to create a passkey for {host}"
|
||||
},
|
||||
{
|
||||
"msgid": "Continue with your passkey to sign in to {host}"
|
||||
},
|
||||
{
|
||||
"msgid": "Copy emoji"
|
||||
},
|
||||
@@ -1816,7 +1822,7 @@
|
||||
"msgid": "Couldn't update passkey two-factor authentication"
|
||||
},
|
||||
{
|
||||
"msgid": "Couldn't use your passkey in the pop-up window. Try again."
|
||||
"msgid": "Couldn't update your passkey. Try again."
|
||||
},
|
||||
{
|
||||
"msgid": "Create administrator account"
|
||||
@@ -1974,6 +1980,9 @@
|
||||
{
|
||||
"msgid": "Finish"
|
||||
},
|
||||
{
|
||||
"msgid": "Finish in the new tab. If you closed it, press Continue with passkey again."
|
||||
},
|
||||
{
|
||||
"msgid": "Finish setup"
|
||||
},
|
||||
@@ -1998,6 +2007,9 @@
|
||||
{
|
||||
"msgid": "Global shortcut"
|
||||
},
|
||||
{
|
||||
"msgid": "Go back to {productName} to continue."
|
||||
},
|
||||
{
|
||||
"msgid": "Go to {communityName}"
|
||||
},
|
||||
@@ -2232,6 +2244,9 @@
|
||||
{
|
||||
"msgid": "No part of {query} could be applied. Fix the underlined value or add something to search for."
|
||||
},
|
||||
{
|
||||
"msgid": "Not accepted"
|
||||
},
|
||||
{
|
||||
"msgid": "Nothing to search for"
|
||||
},
|
||||
@@ -2250,9 +2265,6 @@
|
||||
{
|
||||
"msgid": "Open the new app and choose Sign in with your old {productName} app. Then choose Link a new device here and enter the code it shows."
|
||||
},
|
||||
{
|
||||
"msgid": "Open this window from {productName} to use your passkey."
|
||||
},
|
||||
{
|
||||
"msgid": "Open your old {PRODUCT_NAME} app and choose Link a new device, then enter the code below."
|
||||
},
|
||||
@@ -2290,7 +2302,7 @@
|
||||
"msgid": "Our phone number check is down right now. This is on us, not your number. Wait a few minutes and try the same number again."
|
||||
},
|
||||
{
|
||||
"msgid": "Password managers may not offer your passkey on this web address. Try it in a pop-up window on the old address instead."
|
||||
"msgid": "Passkey updated"
|
||||
},
|
||||
{
|
||||
"msgid": "Pause preview when Fluxer isn’t focused"
|
||||
@@ -2343,6 +2355,9 @@
|
||||
{
|
||||
"msgid": "Preparing"
|
||||
},
|
||||
{
|
||||
"msgid": "Press Continue to use your passkey."
|
||||
},
|
||||
{
|
||||
"msgid": "Prevent <0>{targetUserTag}</0> from sending messages, reacting, and connecting to voice channels for the specified duration."
|
||||
},
|
||||
@@ -2376,12 +2391,18 @@
|
||||
{
|
||||
"msgid": "Public registration is closed."
|
||||
},
|
||||
{
|
||||
"msgid": "Push relay notice"
|
||||
},
|
||||
{
|
||||
"msgid": "Read messages sent before they opened a channel. Without it, they only see messages that arrive while it is open."
|
||||
},
|
||||
{
|
||||
"msgid": "Read messages sent in this channel before they opened it. Without it, they only see messages that arrive while it is open."
|
||||
},
|
||||
{
|
||||
"msgid": "Read the supplemental privacy notice"
|
||||
},
|
||||
{
|
||||
"msgid": "Reason (optional)."
|
||||
},
|
||||
@@ -3054,6 +3075,9 @@
|
||||
{
|
||||
"msgid": "The new price is already scheduled for {effectiveDate}."
|
||||
},
|
||||
{
|
||||
"msgid": "The official Fluxer mobile apps receive notifications through Fluxer's push relay, which hands them to Apple and Google. Self-hosted UnifiedPush and ntfy endpoints never reach the relay and need no agreement."
|
||||
},
|
||||
{
|
||||
"msgid": "The override allowed {permissions}."
|
||||
},
|
||||
@@ -3145,10 +3169,7 @@
|
||||
"msgid": "This option is required. Please provide a value."
|
||||
},
|
||||
{
|
||||
"msgid": "This passkey request could not be verified. Close this window and try again."
|
||||
},
|
||||
{
|
||||
"msgid": "This passkey request timed out. Close this window and try again."
|
||||
"msgid": "This request has expired. Go back and try again."
|
||||
},
|
||||
{
|
||||
"msgid": "This reset link has expired. Reset links last 1 hour. Please request a new one."
|
||||
@@ -3213,15 +3234,18 @@
|
||||
{
|
||||
"msgid": "Unread channels"
|
||||
},
|
||||
{
|
||||
"msgid": "Update passkey"
|
||||
},
|
||||
{
|
||||
"msgid": "Update your passkey"
|
||||
},
|
||||
{
|
||||
"msgid": "Upload files and media in messages."
|
||||
},
|
||||
{
|
||||
"msgid": "Upload files and media in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Use a password manager passkey"
|
||||
},
|
||||
{
|
||||
"msgid": "Use emoji from other communities in this channel."
|
||||
},
|
||||
@@ -3300,6 +3324,9 @@
|
||||
{
|
||||
"msgid": "You asked for {resolution} at {frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "You can close this tab"
|
||||
},
|
||||
{
|
||||
"msgid": "You can still ask the administrators of this instance for a human review at any time."
|
||||
},
|
||||
@@ -3322,7 +3349,7 @@
|
||||
"msgid": "Your account works normally again straight away and nothing is removed. We can ask for this check again later."
|
||||
},
|
||||
{
|
||||
"msgid": "Your browser blocked the passkey pop-up window. Allow pop-ups for this site and try again."
|
||||
"msgid": "Your browser couldn't finish the check."
|
||||
},
|
||||
{
|
||||
"msgid": "Your capture device is sending {deliveredResolution} instead of {resolution}."
|
||||
@@ -3348,6 +3375,12 @@
|
||||
{
|
||||
"msgid": "Your device could not keep up, so your stream now sends {resolution} to keep {frameRate} FPS smooth."
|
||||
},
|
||||
{
|
||||
"msgid": "Your device will ask you to save an updated passkey for {name}. It replaces the old one."
|
||||
},
|
||||
{
|
||||
"msgid": "Your device will ask you to save an updated passkey for {name}. Use the same phone or security key you just used."
|
||||
},
|
||||
{
|
||||
"msgid": "Your new nickname, or leave blank to reset it."
|
||||
},
|
||||
@@ -3861,6 +3894,9 @@
|
||||
{
|
||||
"msgid": "{actor} updated {target}."
|
||||
},
|
||||
{
|
||||
"msgid": "{appName} can't reach its servers. It will keep trying in the background."
|
||||
},
|
||||
{
|
||||
"msgid": "{channelHeading}, {mentionCount, plural, one {# mention} other {# mentions}}"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user