Compare commits

...
Author SHA1 Message Date
Weblate 444dc2b7d4 Merge remote-tracking branch 'origin/main' 2026-09-27 21:29:29 +00:00
HampusandGitHub e2d05a44a8 fix(push): stop retrying relay rate limit refusals (#2993) 2026-09-27 23:29:27 +02:00
Weblate 669bd3c581 Merge remote-tracking branch 'origin/main' 2026-09-27 19:22:45 +00:00
HampusandGitHub 30ba55bd4d fix(gateway): parse push relay hosts as binaries (#2989) 2026-09-27 21:22:45 +02:00
Weblate d4e93d3e84 Merge remote-tracking branch 'origin/main' 2026-09-27 19:19:37 +00:00
HampusandGitHub 9def9fbef6 feat(api): accept CIDR ranges in FLUXER_API_IP_BAN_EXEMPT_IPS (#2988) 2026-09-27 21:19:35 +02:00
Weblate 7c82ca1102 Merge remote-tracking branch 'origin/main' 2026-09-27 19:15:35 +00:00
HampusandGitHub fa3fd0027c fix(i18n): translate the push relay notice strings (#2987) 2026-09-27 21:15:33 +02:00
Weblate 83c1710b47 Merge remote-tracking branch 'origin/main' 2026-09-27 19:03:44 +00:00
HampusandGitHub 7e1b934637 feat(captcha): add ALTCHA proof-of-work captcha experiment (#2986) 2026-09-27 21:02:55 +02:00
Weblate 48cf56e732 Merge remote-tracking branch 'origin/main' 2026-09-27 18:50:09 +00:00
HampusandGitHub 33a118d12a docs(readme): list the Google Play beta first for Android (#2985) 2026-09-27 20:49:39 +02:00
Weblate 6b52de6354 Merge remote-tracking branch 'origin/main' 2026-09-27 18:33:11 +00:00
HampusandGitHub 01f53a168d feat(push): gate relay delivery on operator consent (#2984) 2026-09-27 20:33:10 +02:00
Weblate 59840af1bf Merge remote-tracking branch 'origin/main' 2026-09-27 18:13:15 +00:00
HampusandGitHub 336b8b7dcd fix(forward): make an @silent comment silence the forward too (#2983) 2026-09-27 20:13:14 +02:00
Weblate 2df37450ae Merge remote-tracking branch 'origin/main' 2026-09-27 17:37:41 +00:00
HampusandGitHub 48d0034239 fix(app-proxy): trust the Play app signing certificate (#2982) 2026-09-27 19:37:40 +02:00
Weblate 2fc97f4544 Merge remote-tracking branch 'origin/main' 2026-09-27 14:18:06 +00:00
HampusandGitHub 677ef8491e fix(desktop): back off failed app loads and offer a retry (#2980) 2026-09-27 16:18:01 +02:00
Weblate 2bf3a610f4 Merge remote-tracking branch 'origin/main' 2026-09-27 11:33:25 +00:00
HampusandGitHub 6a6119ed1e fix(push): preview forwarded message content (#2979) 2026-09-27 13:33:22 +02:00
Weblate 86d92564c0 Merge remote-tracking branch 'origin/main' 2026-09-27 11:33:20 +00:00
HampusandGitHub 931327d1dc fix(push): stop sending notifications for system messages (#2978) 2026-09-27 13:33:18 +02:00
Weblate 2edd0f188f Merge remote-tracking branch 'origin/main' 2026-09-26 11:48:31 +00:00
HampusandGitHub 858a2d9e2b fix(oauth): stop granting scopes the user turned off (#2968) 2026-09-26 13:48:23 +02:00
Weblate cb55e62bd9 Merge remote-tracking branch 'origin/main' 2026-09-25 20:35:45 +00:00
HampusandGitHub 841fb7af41 feat(auth): migrate passkeys to fluxer.com (#2964) 2026-09-25 22:33:50 +02:00
Weblate ed579aaeec Merge remote-tracking branch 'origin/main' 2026-09-25 18:13:02 +00:00
Weblate 0808bf680f Merge remote-tracking branch 'origin/main' 2026-09-25 18:10:57 +00:00
Weblate e75ed31a4c Merge remote-tracking branch 'origin/main' 2026-09-25 16:16:27 +00:00
Weblate b6e3fa47a8 Merge remote-tracking branch 'origin/main' 2026-09-25 15:46:14 +00:00
Weblate 690cca6edb Merge remote-tracking branch 'origin/main' 2026-09-25 15:43:23 +00:00
Weblate f28937d86f Merge remote-tracking branch 'origin/main' 2026-09-25 14:45:02 +00:00
Weblate 6b04ad25b1 Merge remote-tracking branch 'origin/main' 2026-09-25 11:59:35 +00:00
Weblate 63980fca11 Merge remote-tracking branch 'origin/main' 2026-09-25 11:43:39 +00:00
Weblate 0d92584431 Merge remote-tracking branch 'origin/main' 2026-09-25 11:42:06 +00:00
Weblate 0e2b7a1a2b Merge remote-tracking branch 'origin/main' 2026-09-25 11:12:21 +00:00
Weblate bbe55397c3 Merge remote-tracking branch 'origin/main' 2026-09-24 21:38:07 +00:00
Weblate 0b5514f663 Merge remote-tracking branch 'origin/main' 2026-09-24 20:50:38 +00:00
Weblate 380995cc19 Merge remote-tracking branch 'origin/main' 2026-09-24 20:45:58 +00:00
Weblate e3522ec7be Merge remote-tracking branch 'origin/main' 2026-09-24 15:52:37 +00:00
Weblate 56bc0e5612 Merge remote-tracking branch 'origin/main' 2026-09-24 15:09:53 +00:00
Weblate d501a1ea68 Merge remote-tracking branch 'origin/main' 2026-09-24 15:09:40 +00:00
Weblate 6e3739bb9b Merge remote-tracking branch 'origin/main' 2026-09-24 14:25:29 +00:00
Weblate b4f789a5ba Merge remote-tracking branch 'origin/main' 2026-09-24 14:25:10 +00:00
Weblate 49761959b1 Merge remote-tracking branch 'origin/main' 2026-09-24 14:21:46 +00:00
Weblate 34e03c9732 Merge remote-tracking branch 'origin/main' 2026-09-24 14:07:06 +00:00
Weblate 58d6e2d4bf Merge remote-tracking branch 'origin/main' 2026-09-24 13:15:50 +00:00
Weblate 4766ce7974 Merge remote-tracking branch 'origin/main' 2026-09-24 13:04:28 +00:00
Weblate 9e6aa67834 Merge remote-tracking branch 'origin/main' 2026-09-24 13:04:06 +00:00
Weblate 57832208d5 Merge remote-tracking branch 'origin/main' 2026-09-24 13:03:37 +00:00
Weblate b35c85fc54 Merge remote-tracking branch 'origin/main' 2026-09-24 12:57:45 +00:00
Weblate 7f58fba66d Merge remote-tracking branch 'origin/main' 2026-09-24 12:51:09 +00:00
Weblate 5b35d6da7b Merge remote-tracking branch 'origin/main' 2026-09-24 12:04:12 +00:00
Weblate 53b9f14f35 Merge remote-tracking branch 'origin/main' 2026-09-24 01:41:30 +00:00
Weblate bb83a6c042 Merge remote-tracking branch 'origin/main' 2026-09-24 00:14:11 +00:00
WeblateandHampus e7125e4e62 Translated using Weblate (Ukrainian)
Currently translated at 98.5% (470 of 477 strings)

Co-authored-by: Hampus <[email protected]>
Translate-URL: https://weblate.fluxer.tools/projects/fluxer/errors/uk/
Translation: Fluxer/Error messages
2026-09-24 00:06:54 +00:00
262 changed files with 29260 additions and 19498 deletions
+4 -3
View File
@@ -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
+88 -5
View File
@@ -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") {
+158 -18
View File
@@ -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 {
+89 -2
View File
@@ -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",
+1
View File
@@ -56,6 +56,7 @@
"@simplewebauthn/server": "catalog:",
"@types/node": "catalog:",
"@vvo/tzdb": "catalog:",
"altcha-lib": "catalog:",
"archiver": "catalog:",
"argon2": "catalog:",
"bowser": "catalog:",
+3 -1
View File
@@ -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);
}
}
+9
View File
@@ -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);
+4 -2
View File
@@ -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(
+25 -15
View File
@@ -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
View File
@@ -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,
};
}
}
+774 -3
View File
@@ -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')},
+49 -14
View File
@@ -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);
});
});
+5 -6
View File
@@ -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,
};
+15
View File
@@ -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);
}
+1
View File
@@ -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">
@@ -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}
@@ -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}
@@ -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>
);
}
+103 -104
View File
@@ -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