mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaee820216 | ||
|
|
564c5ae164 | ||
|
|
dd8ed6f205 | ||
|
|
ed8c412415 | ||
|
|
12417a6942 | ||
|
|
d05f6c9aaa | ||
|
|
0ca035c547 | ||
|
|
dfd46ccc2c | ||
|
|
f6df3169ca | ||
|
|
5b280898c5 | ||
|
|
2a9e25c788 | ||
|
|
463c03fb6d | ||
|
|
153dad11e1 | ||
|
|
e2d05a44a8 | ||
|
|
30ba55bd4d | ||
|
|
9def9fbef6 | ||
|
|
fa3fd0027c | ||
|
|
7e1b934637 | ||
|
|
33a118d12a | ||
|
|
01f53a168d | ||
|
|
336b8b7dcd | ||
|
|
48d0034239 | ||
|
|
677ef8491e | ||
|
|
6a6119ed1e | ||
|
|
931327d1dc | ||
|
|
858a2d9e2b | ||
|
|
841fb7af41 | ||
|
|
08e65d41c0 | ||
|
|
f76c4dc041 | ||
|
|
f1f8ba2031 |
@@ -336,6 +336,9 @@ jobs:
|
||||
restore-keys: |
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.27.0-
|
||||
|
||||
- name: Drop restored gateway build output
|
||||
run: rm -rf fluxer_gateway/_build/default/lib/fluxer_gateway fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
"$FLUXER_CI_BIN" ci --step gateway_fmt
|
||||
|
||||
@@ -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
|
||||
|
||||
+205
-55
@@ -10524,8 +10524,10 @@
|
||||
},
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
|
||||
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
|
||||
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"},
|
||||
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
@@ -10953,8 +10955,10 @@
|
||||
"sso",
|
||||
"gateway_rollout",
|
||||
"voice_noise_suppression",
|
||||
"push_service_delivery",
|
||||
"push_relay",
|
||||
"domain_migration",
|
||||
"altcha_captcha",
|
||||
"profile_timezone",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
@@ -11089,14 +11093,19 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
|
||||
},
|
||||
"push_service_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
|
||||
},
|
||||
"push_relay": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/PushRelayConfigUpdateRequest"}]},
|
||||
"domain_migration": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
|
||||
},
|
||||
"altcha_captcha": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}]
|
||||
},
|
||||
"profile_timezone": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ProfileTimezoneConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
@@ -15190,6 +15199,57 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"ProfileTimezoneConfigUpdateRequest": {
|
||||
"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}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"type": "boolean"},
|
||||
"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": {
|
||||
@@ -15201,6 +15261,12 @@
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
@@ -15210,24 +15276,7 @@
|
||||
"standalone_forwarding": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"PushServiceDeliveryConfigUpdateRequest": {
|
||||
"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}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PushRelayConfigUpdateRequest": {"type": "object", "properties": {"relay_consent_accepted": {"type": "boolean"}}},
|
||||
"VoiceNoiseSuppressionConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15246,6 +15295,12 @@
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
@@ -15293,6 +15348,102 @@
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProfileTimezoneConfigResponse": {
|
||||
"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": "profile-timezone-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}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"default": false, "type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"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}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"default": false, "type": "boolean"},
|
||||
"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",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids",
|
||||
"anonymous_enabled",
|
||||
"cost",
|
||||
"max_counter"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15312,6 +15463,13 @@
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"default": false, "type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
@@ -15327,46 +15485,28 @@
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids",
|
||||
"anonymous_rollout_basis_points",
|
||||
"standalone_forwarding"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PushServiceDeliveryConfigResponse": {
|
||||
"PushRelayConfigResponse": {
|
||||
"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": "push-service-delivery-v1",
|
||||
"relay_consent_accepted": {"default": false, "type": "boolean"},
|
||||
"relay_consent_accepted_at": {
|
||||
"default": null,
|
||||
"nullable": true,
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[\\x20-\\x7e]+$"
|
||||
"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))$"
|
||||
},
|
||||
"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}$"}
|
||||
}
|
||||
"relay_consent_accepted_by": {"default": null, "nullable": true, "type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"required": ["relay_consent_accepted", "relay_consent_accepted_at", "relay_consent_accepted_by"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigResponse": {
|
||||
@@ -15393,6 +15533,13 @@
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"included_guild_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"include_premium_users": {"default": false, "type": "boolean"},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
@@ -15424,6 +15571,8 @@
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"included_guild_ids",
|
||||
"include_premium_users",
|
||||
"excluded_user_ids",
|
||||
"guild_overrides",
|
||||
"suppression_strength"
|
||||
@@ -15711,9 +15860,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": {
|
||||
|
||||
@@ -23,10 +23,14 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
#[serde(default)]
|
||||
pub push_service_delivery: PushServiceDeliveryConfigResponse,
|
||||
pub push_relay: PushRelayConfigResponse,
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub altcha_captcha: AltchaCaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub profile_timezone: ProfileTimezoneConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
}
|
||||
|
||||
@@ -451,8 +455,11 @@ 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 PROFILE_TIMEZONE_DEFAULT_SALT: &str = "profile-timezone-v1";
|
||||
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
@@ -496,6 +503,8 @@ pub struct VoiceNoiseSuppressionConfigResponse {
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
pub include_premium_users: bool,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
|
||||
pub suppression_strength: u32,
|
||||
@@ -512,6 +521,8 @@ impl Default for VoiceNoiseSuppressionConfigResponse {
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "voice-ns-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
guild_overrides: Vec::new(),
|
||||
suppression_strength: 80,
|
||||
@@ -536,6 +547,10 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
|
||||
@@ -543,42 +558,18 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
pub suppression_strength: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct PushServiceDeliveryConfigResponse {
|
||||
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>,
|
||||
}
|
||||
|
||||
impl Default for PushServiceDeliveryConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
pub struct PushRelayConfigResponse {
|
||||
pub relay_consent_accepted: bool,
|
||||
pub relay_consent_accepted_at: Option<String>,
|
||||
pub relay_consent_accepted_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct PushServiceDeliveryConfigUpdateRequest {
|
||||
pub struct PushRelayConfigUpdateRequest {
|
||||
#[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>>,
|
||||
pub relay_consent_accepted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -589,6 +580,8 @@ pub struct DomainMigrationConfigResponse {
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
pub include_premium_users: bool,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub anonymous_rollout_basis_points: u32,
|
||||
pub standalone_forwarding: bool,
|
||||
@@ -602,6 +595,8 @@ impl Default for DomainMigrationConfigResponse {
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
anonymous_rollout_basis_points: 0,
|
||||
standalone_forwarding: false,
|
||||
@@ -620,6 +615,10 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anonymous_rollout_basis_points: Option<u32>,
|
||||
@@ -627,6 +626,110 @@ 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 included_guild_ids: Vec<String>,
|
||||
pub include_premium_users: bool,
|
||||
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(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
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 included_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[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 ProfileTimezoneConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
pub include_premium_users: bool,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ProfileTimezoneConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ProfileTimezoneConfigUpdateRequest {
|
||||
#[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 included_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
@@ -743,10 +846,14 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
|
||||
pub push_relay: Option<PushRelayConfigUpdateRequest>,
|
||||
#[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 profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
@@ -1086,17 +1193,31 @@ 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 profile_timezone = serde_json::from_value::<ProfileTimezoneConfigResponse>(json!({}))
|
||||
.expect("default profile timezone 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 profile_timezone =
|
||||
serde_json::to_value(profile_timezone).expect("serializable profile timezone 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_profile_timezone: generated_types::ProfileTimezoneConfigResponse =
|
||||
serde_json::from_value(profile_timezone.clone())
|
||||
.expect("generated profile timezone config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
assert_eq!(
|
||||
@@ -1108,6 +1229,16 @@ 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_profile_timezone)
|
||||
.expect("serializable generated profile timezone config"),
|
||||
profile_timezone
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_delivery)
|
||||
.expect("serializable generated delivery config"),
|
||||
@@ -1116,6 +1247,8 @@ mod tests {
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("AltchaCaptchaConfigResponse", altcha_captcha),
|
||||
("ProfileTimezoneConfigResponse", profile_timezone),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
|
||||
@@ -4,24 +4,25 @@ use crate::{
|
||||
api::{
|
||||
client::AdminApiClient,
|
||||
types::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, DomainMigrationConfigUpdateRequest,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode,
|
||||
PushServiceDeliveryConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
|
||||
VoiceNoiseSuppressionGuildOverride,
|
||||
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
|
||||
AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest,
|
||||
AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest,
|
||||
AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest,
|
||||
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
|
||||
PremiumMode, ProfileTimezoneConfigUpdateRequest, PushRelayConfigUpdateRequest,
|
||||
RegistrationMode, SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
|
||||
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -208,11 +209,19 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_push_service_delivery" => match build_push_service_delivery_update(&form) {
|
||||
"update_push_relay" => {
|
||||
let update = build_push_relay_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"update_domain_migration" => match build_domain_migration_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_domain_migration" => match build_domain_migration_update(&form) {
|
||||
"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_profile_timezone" => match build_profile_timezone_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
@@ -634,6 +643,12 @@ fn build_voice_noise_suppression_update(
|
||||
form.first("voice_ns_included_user_ids").unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("voice_ns_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
@@ -653,36 +668,13 @@ fn build_voice_noise_suppression_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_push_service_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
push_service_delivery: Some(PushServiceDeliveryConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("push_service_delivery_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"push_service_delivery_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_ascii_experiment_rollout_salt(
|
||||
form,
|
||||
"push_service_delivery_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("push_service_delivery_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("push_service_delivery_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
push_relay: Some(PushRelayConfigUpdateRequest {
|
||||
relay_consent_accepted: Some(form.bool_value("push_relay_consent_accepted")),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_domain_migration_update(
|
||||
@@ -707,6 +699,12 @@ fn build_domain_migration_update(
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("domain_migration_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
@@ -725,6 +723,94 @@ 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",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("altcha_captcha_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("altcha_captcha_include_premium_users")),
|
||||
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_profile_timezone_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
profile_timezone: Some(ProfileTimezoneConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("profile_timezone_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"profile_timezone_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_ascii_experiment_rollout_salt(
|
||||
form,
|
||||
"profile_timezone_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("profile_timezone_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("profile_timezone_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -1369,6 +1455,8 @@ mod tests {
|
||||
"allow_user_override": false,
|
||||
"enabled_backends": [],
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
}})
|
||||
@@ -1685,6 +1773,8 @@ mod tests {
|
||||
serde_json::json!({"domain_migration": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
"standalone_forwarding": false,
|
||||
}})
|
||||
@@ -1731,6 +1821,167 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_push_relay_update_reads_the_consent_checkbox() {
|
||||
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&unchecked).expect("serialize update"),
|
||||
serde_json::json!({"push_relay": {"relay_consent_accepted": false}})
|
||||
);
|
||||
|
||||
let checked = build_push_relay_update(&MultiValueForm::parse(
|
||||
b"_csrf=token&push_relay_consent_accepted=true",
|
||||
));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&checked).expect("serialize update"),
|
||||
serde_json::json!({"push_relay": {"relay_consent_accepted": 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": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"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_profile_timezone_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"profile_timezone_enabled=true&profile_timezone_rollout_basis_points=%20500%20&profile_timezone_rollout_salt=%20profile-timezone-v2%20&profile_timezone_included_user_ids=1500000000000000001&profile_timezone_excluded_user_ids=1500000000000000002&profile_timezone_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&profile_timezone_include_premium_users=true",
|
||||
);
|
||||
let update = build_profile_timezone_update(&form)
|
||||
.expect("valid form")
|
||||
.profile_timezone
|
||||
.expect("profile timezone update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("profile-timezone-v2".to_owned()));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
"1500000000000000005".to_owned(),
|
||||
"1500000000000000006".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_profile_timezone_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"profile_timezone": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
|
||||
for (prefix, build) in [
|
||||
(
|
||||
"voice_ns",
|
||||
build_voice_noise_suppression_update
|
||||
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
|
||||
),
|
||||
("domain_migration", build_domain_migration_update),
|
||||
("altcha_captcha", build_altcha_captcha_update),
|
||||
("profile_timezone", build_profile_timezone_update),
|
||||
] {
|
||||
let form = MultiValueForm::parse(
|
||||
format!("{prefix}_included_guild_ids=1500000000000000005%0Anot-a-guild").as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
build(&form).expect_err("invalid guild id"),
|
||||
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_profile_timezone_update_rejects_a_rollout_above_everybody() {
|
||||
let form = MultiValueForm::parse(b"profile_timezone_rollout_basis_points=10001");
|
||||
assert_eq!(
|
||||
build_profile_timezone_update(&form).expect_err("invalid field"),
|
||||
"Rollout basis points must be a whole number between 0 and 10000"
|
||||
);
|
||||
}
|
||||
|
||||
#[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,
|
||||
PROFILE_TIMEZONE_DEFAULT_SALT, PendingRegistrationResponse, ProfileTimezoneConfigResponse,
|
||||
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -136,6 +138,13 @@ pub fn instance_config_page(
|
||||
(integrations_config_section(base, csrf_token, &instance_config.integrations))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Push notifications",
|
||||
"Consent for the relay that delivers official mobile app notifications.",
|
||||
html! {
|
||||
(push_relay_section(base, csrf_token, &instance_config.push_relay))
|
||||
},
|
||||
))
|
||||
(config_group(
|
||||
"Media & retention",
|
||||
"Attachment expiry rules that can be changed without editing environment variables.",
|
||||
@@ -149,8 +158,9 @@ pub fn instance_config_page(
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(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))
|
||||
(profile_timezone_section(base, csrf_token, &instance_config.profile_timezone))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1117,6 +1127,38 @@ fn voice_noise_suppression_section(
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"voice_ns_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
voice_noise_suppression.include_premium_users,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Includes every account with active premium perks, regardless of the \
|
||||
percentage above. The never-on list still wins."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&voice_noise_suppression.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.included_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format, with guild IDs. Every member of a listed guild is \
|
||||
included regardless of the percentage above, unless the user is \
|
||||
in the never-on list."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_excluded_user_ids",
|
||||
@@ -1179,108 +1221,69 @@ fn voice_noise_suppression_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn push_service_delivery_section(
|
||||
fn push_relay_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
push_service_delivery: &PushServiceDeliveryConfigResponse,
|
||||
push_relay: &PushRelayConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if push_service_delivery.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
let status = if push_relay.relay_consent_accepted {
|
||||
("Accepted", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
("Not accepted", BadgeVariant::Default)
|
||||
};
|
||||
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 accepted_at =
|
||||
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
|
||||
let accepted_by = push_relay
|
||||
.relay_consent_accepted_by
|
||||
.as_deref()
|
||||
.unwrap_or("Nobody");
|
||||
section_card_with_description(
|
||||
"Push Service Delivery",
|
||||
"Routes push notification delivery for the selected accounts through the push service. \
|
||||
Accounts the rollout does not select keep the current path.",
|
||||
"Push Relay",
|
||||
"Official mobile app notifications travel through Fluxer's relay to Apple and Google. \
|
||||
The relay delivers them only after an operator accepts its privacy notice.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_push_service_delivery"} {
|
||||
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
|
||||
(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" }
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (push_service_delivery.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"push_service_delivery_enabled",
|
||||
"push_relay_consent_accepted",
|
||||
"true",
|
||||
"Hand push notifications to the push service",
|
||||
push_service_delivery.enabled,
|
||||
"Accept the push relay supplemental privacy notice",
|
||||
push_relay.relay_consent_accepted,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every notification keeps the \
|
||||
current delivery path, so the rollout and targeting fields below have no \
|
||||
effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"push_service_delivery_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&push_service_delivery.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"push_service_delivery_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&push_service_delivery.rollout_salt,
|
||||
PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
"Until this is accepted official mobile app 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"
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"push_service_delivery_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(form_field_group("Accepted at", "push_relay_consent_accepted_at", false, None, None,
|
||||
html! {
|
||||
input type="text" id="push_relay_consent_accepted_at"
|
||||
value=(accepted_at)
|
||||
disabled class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
(entry_count_hint(
|
||||
push_service_delivery.included_user_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
(form_field_group("Accepted by user ID", "push_relay_consent_accepted_by", false, None, None,
|
||||
html! {
|
||||
input type="text" id="push_relay_consent_accepted_by"
|
||||
value=(accepted_by)
|
||||
disabled class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"push_service_delivery_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
push_service_delivery.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. This is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Push Service Delivery Configuration"))
|
||||
(submit_button("Save Push Relay Settings"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1392,6 +1395,38 @@ fn domain_migration_section(
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"domain_migration_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
domain_migration.include_premium_users,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Includes every account with active premium perks, regardless of the \
|
||||
percentage above. The never-on list still wins."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"domain_migration_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&domain_migration.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
domain_migration.included_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format, with guild IDs. Every member of a listed guild is \
|
||||
included regardless of the percentage above, unless the user is \
|
||||
in the never-on list."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"domain_migration_excluded_user_ids",
|
||||
@@ -1421,6 +1456,314 @@ 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" {
|
||||
(checkbox(
|
||||
"altcha_captcha_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
altcha_captcha.include_premium_users,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Includes every account with active premium perks, regardless of the \
|
||||
percentage above. The never-on list still wins."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"altcha_captcha_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&altcha_captcha.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
altcha_captcha.included_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format, with guild IDs. Every member of a listed guild is \
|
||||
included regardless of the percentage above, unless the user is \
|
||||
in the never-on list."
|
||||
}
|
||||
}
|
||||
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 profile_timezone_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
profile_timezone: &ProfileTimezoneConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if profile_timezone.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = profile_timezone.included_user_ids.join("\n");
|
||||
let excluded_user_ids = profile_timezone.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Profile Timezone",
|
||||
"Lets the selected users save a time zone in profile settings and show their local time \
|
||||
on their profile. Users outside the rollout cannot change it, and a saved time zone \
|
||||
stays hidden from everyone while its owner is outside the rollout.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_profile_timezone"} {
|
||||
(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 " (profile_timezone.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"profile_timezone_enabled",
|
||||
"true",
|
||||
"Serve profile timezone to the selected users",
|
||||
profile_timezone.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state and the kill switch. With this unchecked nobody \
|
||||
sees the setting and every saved time zone is hidden."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"profile_timezone_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&profile_timezone.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into profile timezone, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"profile_timezone_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&profile_timezone.rollout_salt,
|
||||
PROFILE_TIMEZONE_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(
|
||||
"profile_timezone_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.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 profile \
|
||||
timezone regardless of the percentage above. Invalid entries prevent the save."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(checkbox(
|
||||
"profile_timezone_include_premium_users",
|
||||
"true",
|
||||
"Include premium users",
|
||||
profile_timezone.include_premium_users,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Includes every account with active premium perks, regardless of the \
|
||||
percentage above. The never-on list still wins."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"profile_timezone_included_guild_ids",
|
||||
"Always-on Guild IDs",
|
||||
"1500000000000000005\n1500000000000000006",
|
||||
&profile_timezone.included_guild_ids.join("\n"),
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.included_guild_ids.len(),
|
||||
EXPERIMENT_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format, with guild IDs. Every member of a listed guild is \
|
||||
included regardless of the percentage above, unless the user is \
|
||||
in the never-on list."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"profile_timezone_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
profile_timezone.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."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Profile Timezone Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -2086,6 +2429,31 @@ mod tests {
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_relay_section_shows_the_consent_toggle() {
|
||||
let accepted = PushRelayConfigResponse {
|
||||
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()),
|
||||
};
|
||||
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
|
||||
assert!(markup.contains("action=update_push_relay"));
|
||||
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
|
||||
assert!(markup.contains("https://fluxer.com/push-relay"));
|
||||
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
|
||||
assert!(markup.contains("value=\"1130650140672000000\""));
|
||||
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
|
||||
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
|
||||
assert!(!markup.to_lowercase().contains("rollout"));
|
||||
|
||||
let unaccepted =
|
||||
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
|
||||
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
|
||||
assert!(unaccepted.contains("Not accepted"));
|
||||
assert!(unaccepted.contains("value=\"Never\""));
|
||||
assert!(unaccepted.contains("value=\"Nobody\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
|
||||
@@ -403,19 +403,18 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"included_guild_ids": ["1500000000000000005"],
|
||||
"include_premium_users": true,
|
||||
"suppression_strength": 80,
|
||||
"future_presentation_knob": "verbose",
|
||||
"future_knob": 7,
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"]
|
||||
},
|
||||
"push_service_delivery": {
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "push-service-delivery-v1",
|
||||
"included_user_ids": ["1500000000000000002"],
|
||||
"excluded_user_ids": []
|
||||
"push_relay": {
|
||||
"relay_consent_accepted": true,
|
||||
"relay_consent_accepted_at": "2026-09-27T10:11:12.000Z",
|
||||
"relay_consent_accepted_by": "1130650140672000000"
|
||||
},
|
||||
"domain_migration": {
|
||||
"enabled": true,
|
||||
@@ -424,10 +423,37 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"rollout_salt": "domain-migration-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"future_migration_knob": 9,
|
||||
"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,
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"future_altcha_knob": "argon2id"
|
||||
},
|
||||
"profile_timezone": {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "profile-timezone-v1",
|
||||
"included_user_ids": ["1500000000000000001"],
|
||||
"excluded_user_ids": [],
|
||||
"included_guild_ids": ["1500000000000000005"],
|
||||
"include_premium_users": true,
|
||||
"future_profile_timezone_knob": true
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
@@ -564,6 +590,18 @@ 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_relay.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!(resp.profile_timezone.enabled);
|
||||
assert_eq!(resp.profile_timezone.config_version, 2);
|
||||
assert_eq!(resp.profile_timezone.included_user_ids.len(), 1);
|
||||
assert_eq!(resp.profile_timezone.included_guild_ids.len(), 1);
|
||||
assert!(resp.profile_timezone.include_premium_users);
|
||||
assert!(resp.voice_noise_suppression.include_premium_users);
|
||||
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 +634,51 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_push_relay_config() {
|
||||
let accepted: types::PushRelayConfigResponse = serde_json::from_str(
|
||||
r#"{
|
||||
"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 empty: types::PushRelayConfigResponse =
|
||||
serde_json::from_str("{}").expect("an empty push relay config must deserialize");
|
||||
|
||||
assert!(!empty.relay_consent_accepted);
|
||||
assert!(empty.relay_consent_accepted_at.is_none());
|
||||
assert!(empty.relay_consent_accepted_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_push_relay_update_omits_an_unset_consent() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(types::PushRelayConfigUpdateRequest::default()).unwrap(),
|
||||
serde_json::json!({})
|
||||
);
|
||||
|
||||
let with = types::PushRelayConfigUpdateRequest {
|
||||
relay_consent_accepted: Some(true),
|
||||
};
|
||||
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 +953,8 @@ fn deserialize_webauthn_credentials_response() {
|
||||
"id": "credential-a",
|
||||
"name": "YubiKey",
|
||||
"created_at": "2026-05-26T12:00:00.000Z",
|
||||
"last_used_at": null
|
||||
"last_used_at": null,
|
||||
"rp_id": "fluxer.com"
|
||||
},
|
||||
{
|
||||
"id": "credential-b",
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@simplewebauthn/server": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@vvo/tzdb": "catalog:",
|
||||
"altcha-lib": "catalog:",
|
||||
"archiver": "catalog:",
|
||||
"argon2": "catalog:",
|
||||
"bowser": "catalog:",
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/logger": "workspace:*",
|
||||
"itty-time": "catalog:"
|
||||
"altcha-lib": "catalog:",
|
||||
"itty-time": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface VerifyCaptchaParams {
|
||||
remoteIp?: string;
|
||||
}
|
||||
|
||||
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'test' | 'unavailable';
|
||||
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable';
|
||||
|
||||
export interface ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {createChallenge, randomInt, verifySolution} from 'altcha-lib';
|
||||
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
import {ms} from 'itty-time';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
|
||||
const ALTCHA_CHALLENGE_TTL_MS = ms('10 minutes');
|
||||
const ALTCHA_MAX_TOKEN_LENGTH = 4096;
|
||||
const HEX_PATTERN = /^[0-9a-f]+$/u;
|
||||
|
||||
const AltchaPayloadSchema = z.object({
|
||||
challenge: z.object({
|
||||
parameters: z.looseObject({
|
||||
algorithm: z.literal(ALTCHA_ALGORITHM),
|
||||
nonce: z.string().regex(HEX_PATTERN),
|
||||
salt: z.string().regex(HEX_PATTERN),
|
||||
cost: z.number().int().positive(),
|
||||
keyLength: z.number().int().positive(),
|
||||
keyPrefix: z.string().regex(HEX_PATTERN),
|
||||
keySignature: z.string().regex(HEX_PATTERN),
|
||||
expiresAt: z.number().int().positive(),
|
||||
}),
|
||||
signature: z.string().regex(HEX_PATTERN),
|
||||
}),
|
||||
solution: z.object({
|
||||
counter: z.number().int().min(0),
|
||||
derivedKey: z.string().regex(HEX_PATTERN),
|
||||
time: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
type AltchaPayload = z.infer<typeof AltchaPayloadSchema>;
|
||||
|
||||
export interface AltchaProviderOptions {
|
||||
hmacSignatureSecret: string;
|
||||
hmacKeySignatureSecret: string;
|
||||
cost: number;
|
||||
maxCounter: number;
|
||||
claimChallenge: (signature: string, ttlSeconds: number) => Promise<boolean>;
|
||||
logger?: LoggerInterface;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function decodePayload(token: string): AltchaPayload | null {
|
||||
if (token.length > ALTCHA_MAX_TOKEN_LENGTH) return null;
|
||||
try {
|
||||
const parsed = AltchaPayloadSchema.safeParse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class AltchaProvider implements ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'altcha';
|
||||
private readonly options: AltchaProviderOptions;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: AltchaProviderOptions) {
|
||||
this.options = options;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async createChallenge(): Promise<Challenge> {
|
||||
const {cost, maxCounter, hmacSignatureSecret, hmacKeySignatureSecret} = this.options;
|
||||
return await createChallenge({
|
||||
algorithm: ALTCHA_ALGORITHM,
|
||||
cost,
|
||||
counter: randomInt(maxCounter, Math.ceil(maxCounter / 2)),
|
||||
deriveKey,
|
||||
expiresAt: new Date(this.now() + ALTCHA_CHALLENGE_TTL_MS),
|
||||
hmacSignatureSecret,
|
||||
hmacKeySignatureSecret,
|
||||
});
|
||||
}
|
||||
|
||||
async verify({token}: VerifyCaptchaParams): Promise<boolean> {
|
||||
const payload = decodePayload(token);
|
||||
if (!payload) return false;
|
||||
try {
|
||||
const result = await verifySolution({
|
||||
challenge: payload.challenge,
|
||||
solution: payload.solution,
|
||||
deriveKey,
|
||||
hmacSignatureSecret: this.options.hmacSignatureSecret,
|
||||
hmacKeySignatureSecret: this.options.hmacKeySignatureSecret,
|
||||
});
|
||||
if (!result.verified) {
|
||||
this.options.logger?.warn(
|
||||
{expired: result.expired, invalidSignature: result.invalidSignature, invalidSolution: result.invalidSolution},
|
||||
'ALTCHA verification failed',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger?.error({error}, 'Error verifying ALTCHA payload');
|
||||
return false;
|
||||
}
|
||||
const ttlSeconds = Math.max(1, payload.challenge.parameters.expiresAt - Math.floor(this.now() / 1000));
|
||||
return await this.options.claimChallenge(payload.challenge.signature, ttlSeconds);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
|
||||
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
@@ -82,6 +83,14 @@ function resolveTrustClientIpHeader(proxyConfig: object): boolean {
|
||||
function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
|
||||
const normalized = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (value.includes('/')) {
|
||||
const range = parseIpBanEntry(value);
|
||||
if (range?.type !== 'range') {
|
||||
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid CIDR range: ${value}`);
|
||||
}
|
||||
normalized.add(range.canonical);
|
||||
continue;
|
||||
}
|
||||
const parsed = parseIpAddress(value);
|
||||
if (!parsed) {
|
||||
throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid IP address: ${value}`);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {
|
||||
getGatewayRolloutConfigPublisher,
|
||||
getInstanceConfigRepository,
|
||||
getPushServiceDeliveryConfigPublisher,
|
||||
getPushRelayConfigPublisher,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
|
||||
@@ -34,9 +34,11 @@ 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 {ProfileTimezoneConfigSchema} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
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';
|
||||
@@ -65,8 +67,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
ssoConfig,
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
pushServiceDelivery,
|
||||
pushRelay,
|
||||
domainMigration,
|
||||
altchaCaptcha,
|
||||
profileTimezone,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -75,8 +79,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getSsoConfig(),
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getPushServiceDeliveryConfig(),
|
||||
instanceConfigRepository.getPushRelayConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getProfileTimezoneConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
@@ -108,8 +114,10 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
},
|
||||
gateway_rollout: gatewayRollout,
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
push_service_delivery: pushServiceDelivery,
|
||||
push_relay: pushRelay,
|
||||
domain_migration: domainMigration,
|
||||
altcha_captcha: altchaCaptcha,
|
||||
profile_timezone: profileTimezone,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -194,6 +202,20 @@ async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boole
|
||||
return true;
|
||||
}
|
||||
|
||||
function relayConsentStamp(
|
||||
current: PushRelayConfig,
|
||||
patch: PushRelayConfigUpdateRequest,
|
||||
adminUserId: string,
|
||||
): Partial<PushRelayConfig> {
|
||||
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)
|
||||
@@ -273,17 +295,16 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.push_service_delivery) {
|
||||
const patch = omitUndefinedFields(data.push_service_delivery);
|
||||
if (data.push_relay) {
|
||||
const patch = omitUndefinedFields(data.push_relay);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const landed = await instanceConfigRepository.updatePushServiceDeliveryConfig((current) =>
|
||||
PushServiceDeliveryConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
await getPushServiceDeliveryConfigPublisher().publish(landed);
|
||||
const adminUserId = ctx.get('adminUserId').toString();
|
||||
const landed = await instanceConfigRepository.updatePushRelayConfig((current) => ({
|
||||
...current,
|
||||
...patch,
|
||||
...relayConsentStamp(current, patch, adminUserId),
|
||||
}));
|
||||
await getPushRelayConfigPublisher().publish(landed);
|
||||
}
|
||||
}
|
||||
if (data.domain_migration) {
|
||||
@@ -298,6 +319,30 @@ 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.profile_timezone) {
|
||||
const patch = omitUndefinedFields(data.profile_timezone);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateProfileTimezoneConfig((current) =>
|
||||
ProfileTimezoneConfigSchema.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(
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
|
||||
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
|
||||
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
@@ -16,12 +16,12 @@ import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
|
||||
type PushServiceDeliveryConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
type LegacyPushServiceDeliveryWire,
|
||||
toLegacyPushServiceDeliveryWire,
|
||||
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
|
||||
describe('instance config admin PATCH under concurrent writes', () => {
|
||||
let harness: ApiTestHarness;
|
||||
@@ -55,13 +55,13 @@ describe('instance config admin PATCH under concurrent writes', () => {
|
||||
const patchConfig = (admin: TestAccount, body: Record<string, unknown>) =>
|
||||
createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
|
||||
|
||||
const spyOnPushDeliveryPublishes = () =>
|
||||
vi.spyOn(PushServiceDeliveryConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
const spyOnPushRelayPublishes = () =>
|
||||
vi.spyOn(PushRelayConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
|
||||
async function readStoredPushServiceDelivery(): Promise<PushServiceDeliveryConfig> {
|
||||
const raw = await executor.readDirectly(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
|
||||
if (raw === null) throw new Error('push service delivery config was never stored');
|
||||
return JSON.parse(raw) as PushServiceDeliveryConfig;
|
||||
async function readStoredPushRelay(): Promise<LegacyPushServiceDeliveryWire> {
|
||||
const raw = await executor.readDirectly(PUSH_RELAY_CONFIG_KEY);
|
||||
if (raw === null) throw new Error('push relay config was never stored');
|
||||
return JSON.parse(raw) as LegacyPushServiceDeliveryWire;
|
||||
}
|
||||
|
||||
async function listConfigUpdateAudits(): Promise<Array<AdminAuditLog>> {
|
||||
@@ -84,37 +84,32 @@ describe('instance config admin PATCH under concurrent writes', () => {
|
||||
});
|
||||
|
||||
it('answers with a conflict and neither writes, publishes nor audits once every attempt has lost the race', async () => {
|
||||
const publish = spyOnPushDeliveryPublishes();
|
||||
const publish = spyOnPushRelayPublishes();
|
||||
const admin = await createAdmin();
|
||||
await patchConfig(admin, {push_service_delivery: {enabled: true, rollout_basis_points: 1000}}).execute();
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
|
||||
publish.mockClear();
|
||||
const auditsBefore = await listConfigUpdateAudits();
|
||||
executor.watch(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
|
||||
executor.watch(PUSH_RELAY_CONFIG_KEY);
|
||||
const unaccepted = {
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
};
|
||||
let competingWrites = 0;
|
||||
executor.competeBeforeEachWrite(async () => {
|
||||
competingWrites++;
|
||||
await executor.writeDirectly(
|
||||
PUSH_SERVICE_DELIVERY_CONFIG_KEY,
|
||||
JSON.stringify({
|
||||
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
|
||||
enabled: false,
|
||||
rollout_basis_points: 1000,
|
||||
config_version: 100 + competingWrites,
|
||||
}),
|
||||
PUSH_RELAY_CONFIG_KEY,
|
||||
JSON.stringify(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites)),
|
||||
);
|
||||
});
|
||||
|
||||
await patchConfig(admin, {push_service_delivery: {rollout_basis_points: 5000}})
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}})
|
||||
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.CONFLICT)
|
||||
.execute();
|
||||
|
||||
expect(executor.events).not.toContain('write');
|
||||
expect(await readStoredPushServiceDelivery()).toEqual({
|
||||
...DEFAULT_PUSH_SERVICE_DELIVERY_CONFIG,
|
||||
enabled: false,
|
||||
rollout_basis_points: 1000,
|
||||
config_version: 100 + competingWrites,
|
||||
});
|
||||
expect(await readStoredPushRelay()).toEqual(toLegacyPushServiceDeliveryWire(unaccepted, 100 + competingWrites));
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
expect(await listConfigUpdateAudits()).toHaveLength(auditsBefore.length);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// 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 {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
|
||||
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
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 type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const ACCEPTED_AT = '2026-09-20T08:00:00.000Z';
|
||||
const ACCEPTED_BY = '1500000000000000007';
|
||||
|
||||
const PROD_ROW = {
|
||||
enabled: true,
|
||||
config_version: 41,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: ACCEPTED_AT,
|
||||
relay_consent_accepted_by: ACCEPTED_BY,
|
||||
};
|
||||
|
||||
interface PushServiceDeliveryRpcResponse {
|
||||
type: 'get_push_service_delivery_config';
|
||||
data: {config: LegacyPushServiceDeliveryWire};
|
||||
}
|
||||
|
||||
describe('push relay supplemental notice consent', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let executor: InstanceConfigWriteRaceExecutor;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
vi.spyOn(PushRelayConfigPublisher.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');
|
||||
|
||||
const readRpcConfig = async (): Promise<LegacyPushServiceDeliveryWire> => {
|
||||
const response = await createBuilder<PushServiceDeliveryRpcResponse>(harness, '')
|
||||
.post('/test/rpc-session-init')
|
||||
.body({type: 'get_push_service_delivery_config'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.type).toBe('get_push_service_delivery_config');
|
||||
return response.data.config;
|
||||
};
|
||||
|
||||
async function storeRow(row: Record<string, unknown>): Promise<void> {
|
||||
await executor.writeDirectly(PUSH_RELAY_CONFIG_KEY, JSON.stringify(row));
|
||||
getInstanceConfigRepository().clearCacheForTesting();
|
||||
}
|
||||
|
||||
async function readStoredRow(): Promise<unknown> {
|
||||
const raw = await executor.readDirectly(PUSH_RELAY_CONFIG_KEY);
|
||||
if (raw === null) throw new Error('push relay config was never stored');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
it('reads back as unaccepted before an operator agrees', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const config = await readConfig(admin).execute();
|
||||
|
||||
expect(config.push_relay).toEqual({
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the consent of a stored push service delivery row', async () => {
|
||||
const admin = await createAdmin();
|
||||
await storeRow(PROD_ROW);
|
||||
|
||||
const config = await readConfig(admin).execute();
|
||||
|
||||
expect(config.push_relay).toEqual({
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: ACCEPTED_AT,
|
||||
relay_consent_accepted_by: ACCEPTED_BY,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a stored row without consent fields as unaccepted', async () => {
|
||||
const admin = await createAdmin();
|
||||
await storeRow({enabled: true, config_version: 3, rollout_basis_points: 10000});
|
||||
|
||||
const config = await readConfig(admin).execute();
|
||||
|
||||
expect(config.push_relay.relay_consent_accepted).toBe(false);
|
||||
expect(await readRpcConfig()).toMatchObject({config_version: 3, relay_consent_accepted: false});
|
||||
});
|
||||
|
||||
it('stamps the acting admin and the acceptance time when consent is given', async () => {
|
||||
const admin = await createAdmin();
|
||||
|
||||
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(updated.push_relay.relay_consent_accepted).toBe(true);
|
||||
expect(updated.push_relay.relay_consent_accepted_by).toBe(admin.userId);
|
||||
expect(Date.parse(updated.push_relay.relay_consent_accepted_at ?? '')).not.toBeNaN();
|
||||
});
|
||||
|
||||
it('keeps the stamp untouched when consent is re-sent unchanged', async () => {
|
||||
const admin = await createAdmin();
|
||||
const accepted = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const resent = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(resent.push_relay).toEqual(accepted.push_relay);
|
||||
});
|
||||
|
||||
it('clears the stamp when an operator withdraws consent', async () => {
|
||||
const admin = await createAdmin();
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
const withdrawn = await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
|
||||
|
||||
expect(withdrawn.push_relay).toEqual({
|
||||
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_relay: {
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: '2020-01-01T00:00:00.000Z',
|
||||
relay_consent_accepted_by: '1500000000000000009',
|
||||
},
|
||||
}).execute();
|
||||
|
||||
expect(updated.push_relay.relay_consent_accepted_at).not.toBe('2020-01-01T00:00:00.000Z');
|
||||
expect(updated.push_relay.relay_consent_accepted_by).toBe(admin.userId);
|
||||
});
|
||||
|
||||
it('writes the full legacy document and bumps the stored config version', async () => {
|
||||
const admin = await createAdmin();
|
||||
await storeRow({
|
||||
...PROD_ROW,
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
|
||||
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(await readStoredRow()).toEqual({
|
||||
enabled: true,
|
||||
config_version: 42,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: admin.userId,
|
||||
});
|
||||
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
|
||||
|
||||
expect(await readStoredRow()).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 43,
|
||||
rollout_basis_points: 10000,
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rewrites a partially enrolled stored row as full enrolment', async () => {
|
||||
const admin = await createAdmin();
|
||||
await storeRow({
|
||||
...PROD_ROW,
|
||||
enabled: false,
|
||||
rollout_basis_points: 250,
|
||||
rollout_salt: 'custom-salt',
|
||||
included_user_ids: ['1500000000000000003'],
|
||||
excluded_user_ids: ['1500000000000000004'],
|
||||
});
|
||||
|
||||
await patchConfig(admin, {push_relay: {relay_consent_accepted: false}}).execute();
|
||||
|
||||
expect(await readStoredRow()).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 42,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes the legacy delivery document with the consent', async () => {
|
||||
const admin = await createAdmin();
|
||||
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
|
||||
|
||||
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
expect(publish).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: admin.userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write or publish for an empty push relay patch', async () => {
|
||||
const admin = await createAdmin();
|
||||
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
|
||||
|
||||
await patchConfig(admin, {push_relay: {}}).execute();
|
||||
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
expect(await executor.readDirectly(PUSH_RELAY_CONFIG_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores the retired push_service_delivery section', async () => {
|
||||
const admin = await createAdmin();
|
||||
const publish = vi.mocked(PushRelayConfigPublisher.prototype.publish);
|
||||
|
||||
const updated = await patchConfig(admin, {push_service_delivery: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(updated.push_relay.relay_consent_accepted).toBe(false);
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers the legacy delivery RPC with full enrolment and the stored consent', async () => {
|
||||
await storeRow(PROD_ROW);
|
||||
|
||||
expect(await readRpcConfig()).toEqual(PROD_ROW);
|
||||
});
|
||||
|
||||
it('answers the legacy delivery RPC with defaults before anything is stored', async () => {
|
||||
expect(await readRpcConfig()).toEqual({
|
||||
enabled: true,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_salt: 'push-service-delivery-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
relay_consent_accepted: false,
|
||||
relay_consent_accepted_at: null,
|
||||
relay_consent_accepted_by: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers the legacy delivery RPC with consent given through the admin API', async () => {
|
||||
const admin = await createAdmin();
|
||||
const updated = await patchConfig(admin, {push_relay: {relay_consent_accepted: true}}).execute();
|
||||
|
||||
expect(await readRpcConfig()).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
relay_consent_accepted: true,
|
||||
relay_consent_accepted_at: updated.push_relay.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: admin.userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {registerAdminControllers} from '@app/api/admin/controllers/index';
|
||||
import {AttachmentController} from '@app/api/attachment/AttachmentController';
|
||||
import {AuthController} from '@app/api/auth/AuthController';
|
||||
import {OriginHandoffController} from '@app/api/auth/OriginHandoffController';
|
||||
import {PasskeyBridgeController} from '@app/api/auth/PasskeyBridgeController';
|
||||
import {BlueskyOAuthController} from '@app/api/bluesky/BlueskyOAuthController';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ChannelController} from '@app/api/channel/ChannelController';
|
||||
@@ -48,6 +49,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
registerAdminControllers(routes);
|
||||
AuthController(routes);
|
||||
OriginHandoffController(routes);
|
||||
PasskeyBridgeController(routes);
|
||||
AttachmentController(routes);
|
||||
ChannelController(routes);
|
||||
ConnectionController(routes);
|
||||
|
||||
@@ -447,7 +447,7 @@ export function AuthController(app: HonoApp) {
|
||||
'Retrieve WebAuthn authentication challenge and options for passwordless login with biometrics or security keys.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnAuthenticationOptions());
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnAuthenticationOptions(ctx.req.header('origin')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -490,7 +490,9 @@ export function AuthController(app: HonoApp) {
|
||||
'Retrieve WebAuthn challenge and options for multi-factor authentication. Requires the MFA ticket from initial login.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('authRequestService').getWebAuthnMfaOptions(ctx.req.valid('json')));
|
||||
return ctx.json(
|
||||
await ctx.get('authRequestService').getWebAuthnMfaOptions(ctx.req.valid('json'), ctx.req.header('origin')),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {InviteService} from '@app/api/invite/InviteService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {AuthSession as AuthSessionModel} from '@app/api/models/AuthSession';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
|
||||
@@ -353,7 +354,7 @@ export async function login(
|
||||
const MFA_TICKET_MAX_ATTEMPTS = 5;
|
||||
const MFA_USER_MAX_ATTEMPTS = 10;
|
||||
|
||||
async function consumeMfaAttempt(
|
||||
export async function consumeMfaAttempt(
|
||||
ctx: ApiContext,
|
||||
{userId, ticket, field}: {userId: string; ticket: string; field: string},
|
||||
): Promise<void> {
|
||||
@@ -381,7 +382,7 @@ export async function loginMfaTotp(
|
||||
ctx: ApiContext,
|
||||
{code, ticket, request}: LoginMfaTotpParams,
|
||||
): Promise<LoginTokenResult> {
|
||||
const {users, cache, rateLimit} = ctx.services;
|
||||
const {users, cache} = ctx.services;
|
||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
@@ -405,21 +406,36 @@ export async function loginMfaTotp(
|
||||
if (!isValid) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
const [token] = await completeMfaLogin(ctx, user, ticket, request);
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
export async function createLoginSession(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
request: Request,
|
||||
): Promise<[token: string, AuthSessionModel]> {
|
||||
return AuthSession.createAuthSession(ctx, {user, origin: AuthSession.resolveSessionOrigin(ctx, request)});
|
||||
}
|
||||
|
||||
export async function completeMfaLogin(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
ticket: string,
|
||||
request: Request,
|
||||
): Promise<[token: string, AuthSessionModel]> {
|
||||
const {cache, rateLimit} = ctx.services;
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: user.id.toString(), token};
|
||||
return createLoginSession(ctx, user, request);
|
||||
}
|
||||
|
||||
export async function loginMfaWebAuthn(
|
||||
ctx: ApiContext,
|
||||
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
|
||||
): Promise<LoginTokenResult> {
|
||||
const {users, cache, rateLimit} = ctx.services;
|
||||
const {users, cache} = ctx.services;
|
||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
@@ -434,13 +450,7 @@ export async function loginMfaWebAuthn(
|
||||
}
|
||||
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
const [token] = await completeMfaLogin(ctx, user, ticket, request);
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
|
||||
+269
-155
@@ -3,13 +3,20 @@
|
||||
import {timingSafeEqual} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {
|
||||
type CredentialRpSelection,
|
||||
effectiveRpId,
|
||||
originRpId,
|
||||
selectCredentialRp,
|
||||
visibleWebAuthnCredentials,
|
||||
} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import {deriveSudoMethods, userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {MfaBackupCode} from '@app/api/models/MfaBackupCode';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {mapUserToPrivateResponse, mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
|
||||
import {TotpGenerator} from '@app/api/utils/TotpGenerator';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -22,7 +29,12 @@ import {PasskeyAuthenticationFailedError} from '@fluxer/errors/src/domains/auth/
|
||||
import {UnknownWebAuthnCredentialError} from '@fluxer/errors/src/domains/auth/UnknownWebAuthnCredentialError';
|
||||
import {WebAuthnCredentialLimitReachedError} from '@fluxer/errors/src/domains/auth/WebAuthnCredentialLimitReachedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {AuthenticationResponseJSON, RegistrationResponseJSON} from '@simplewebauthn/server';
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/server';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
@@ -33,7 +45,41 @@ import {
|
||||
} from '@simplewebauthn/server';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
|
||||
type WebAuthnChallengeContext = 'registration' | 'discoverable' | 'mfa' | 'sudo';
|
||||
type WebAuthnChallengeContext = 'registration' | 'discoverable' | 'mfa' | 'sudo' | 'bridge' | 'migration_registration';
|
||||
|
||||
interface WebAuthnChallengeEntry {
|
||||
context: WebAuthnChallengeContext;
|
||||
userId?: string;
|
||||
ticket?: string;
|
||||
rpId?: string;
|
||||
credentialIds?: Array<string> | null;
|
||||
}
|
||||
|
||||
interface WebAuthnChallengeScope {
|
||||
rpId: string;
|
||||
credentialIds: Array<string> | null;
|
||||
}
|
||||
|
||||
interface WebAuthnAuthenticationOptionsParams {
|
||||
selection: CredentialRpSelection | {rpId: string; credentials: null};
|
||||
context: WebAuthnChallengeContext;
|
||||
userId?: UserID;
|
||||
ticket?: string;
|
||||
}
|
||||
|
||||
interface WebAuthnRegistrationOptionsParams {
|
||||
rpId: string;
|
||||
context: WebAuthnChallengeContext;
|
||||
excludeCredentials: Array<WebAuthnCredential>;
|
||||
}
|
||||
|
||||
interface VerifiedWebAuthnRegistration {
|
||||
credentialId: string;
|
||||
publicKey: Buffer;
|
||||
counter: bigint;
|
||||
transports: Set<string> | null;
|
||||
rpId: string;
|
||||
}
|
||||
|
||||
interface SudoMfaVerificationParams {
|
||||
userId: UserID;
|
||||
@@ -122,37 +168,117 @@ export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnRegistrationOptions(ctx: ApiContext, userId: UserID) {
|
||||
function toCredentialDescriptor(credential: WebAuthnCredential) {
|
||||
return {
|
||||
id: credential.credentialId,
|
||||
transports: credential.transports
|
||||
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function storedRpId(ctx: ApiContext, rpId: string): string | null {
|
||||
return rpId === ctx.services.config.auth.passkeys.rpId ? null : rpId;
|
||||
}
|
||||
|
||||
export async function createWebAuthnRegistrationOptions(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
{rpId, context, excludeCredentials}: WebAuthnRegistrationOptionsParams,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const {users, config} = ctx.services;
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
const existingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
if (existingCredentials.length >= 10) {
|
||||
throw new WebAuthnCredentialLimitReachedError();
|
||||
}
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: config.auth.passkeys.rpName,
|
||||
rpID: config.auth.passkeys.rpId,
|
||||
rpID: rpId,
|
||||
userID: new TextEncoder().encode(user.id.toString()),
|
||||
userName: user.username!,
|
||||
userDisplayName: user.username!,
|
||||
attestationType: 'none',
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
excludeCredentials: existingCredentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
excludeCredentials: excludeCredentials.map(toCredentialDescriptor),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'registration', userId});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context, userId, rpId, credentialIds: null});
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnRegistrationOptions(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const existingCredentials = await ctx.services.users.listWebAuthnCredentials(userId);
|
||||
if (visibleWebAuthnCredentials(existingCredentials).length >= 10) {
|
||||
throw new WebAuthnCredentialLimitReachedError();
|
||||
}
|
||||
return createWebAuthnRegistrationOptions(ctx, userId, {
|
||||
rpId: originRpId(ctx, origin),
|
||||
context: 'registration',
|
||||
excludeCredentials: existingCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnRegistrationResponse(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
response: RegistrationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
context: WebAuthnChallengeContext,
|
||||
expectedOrigin: Array<string> = ctx.services.config.auth.passkeys.allowedOrigins,
|
||||
): Promise<VerifiedWebAuthnRegistration> {
|
||||
const {config} = ctx.services;
|
||||
const {rpId} = await consumeWebAuthnChallenge(ctx, expectedChallenge, context, {userId});
|
||||
const responseObj = response as {id?: string; response?: {transports?: Array<string>}};
|
||||
const transports = responseObj.response?.transports ? new Set(responseObj.response.transports) : null;
|
||||
if (config.dev.testModeEnabled) {
|
||||
const credentialId = responseObj.id ?? `test-credential:${userId.toString()}:${Date.now()}`;
|
||||
return {credentialId, publicKey: Buffer.from(`test-public-key:${credentialId}`), counter: 0n, transports, rpId};
|
||||
}
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpId,
|
||||
requireUserVerification: false,
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, expectedChallenge, rpId, expectedOrigin}, 'WebAuthn verification failed');
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
Logger.error(
|
||||
{userId, verified: verification.verified, hasRegistrationInfo: !!verification.registrationInfo},
|
||||
'WebAuthn verification result invalid',
|
||||
);
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
const {credential} = verification.registrationInfo;
|
||||
let publicKeyBuffer: Buffer;
|
||||
let counterBigInt: bigint;
|
||||
try {
|
||||
publicKeyBuffer = Buffer.from(credential.publicKey);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnPublicKeyFormatError();
|
||||
}
|
||||
try {
|
||||
if (credential.counter === undefined || credential.counter === null) {
|
||||
throw new Error('Counter value is undefined or null');
|
||||
}
|
||||
counterBigInt = BigInt(credential.counter);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnCredentialCounterError();
|
||||
}
|
||||
return {credentialId: credential.id, publicKey: publicKeyBuffer, counter: counterBigInt, transports, rpId};
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnRegistration(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
@@ -160,85 +286,39 @@ export async function verifyWebAuthnRegistration(
|
||||
expectedChallenge: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const {users, config} = ctx.services;
|
||||
const {users} = ctx.services;
|
||||
const existingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
await consumeWebAuthnChallenge(ctx, expectedChallenge, 'registration', {userId});
|
||||
if (existingCredentials.length >= 10) {
|
||||
if (visibleWebAuthnCredentials(existingCredentials).length >= 10) {
|
||||
throw new WebAuthnCredentialLimitReachedError();
|
||||
}
|
||||
if (config.dev.testModeEnabled) {
|
||||
const responseObj = response as {id?: string; response?: {transports?: Array<string>}};
|
||||
const credentialId = responseObj.id ?? `test-credential:${userId.toString()}:${Date.now()}`;
|
||||
const publicKeyBuffer = Buffer.from(`test-public-key:${credentialId}`);
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
credentialId,
|
||||
publicKeyBuffer,
|
||||
0n,
|
||||
responseObj.response?.transports ? new Set(responseObj.response.transports) : null,
|
||||
name,
|
||||
);
|
||||
} else {
|
||||
const expectedOrigin = config.auth.passkeys.allowedOrigins;
|
||||
const rpID = config.auth.passkeys.rpId;
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, expectedChallenge, rpID, expectedOrigin}, 'WebAuthn verification failed');
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
Logger.error(
|
||||
{userId, verified: verification.verified, hasRegistrationInfo: !!verification.registrationInfo},
|
||||
'WebAuthn verification result invalid',
|
||||
);
|
||||
throw new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
const {credential} = verification.registrationInfo;
|
||||
let publicKeyBuffer: Buffer;
|
||||
let counterBigInt: bigint;
|
||||
try {
|
||||
publicKeyBuffer = Buffer.from(credential.publicKey);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnPublicKeyFormatError();
|
||||
}
|
||||
try {
|
||||
if (credential.counter === undefined || credential.counter === null) {
|
||||
throw new Error('Counter value is undefined or null');
|
||||
}
|
||||
counterBigInt = BigInt(credential.counter);
|
||||
} catch (_error) {
|
||||
throw new InvalidWebAuthnCredentialCounterError();
|
||||
}
|
||||
const responseObj = response as {response?: {transports?: Array<string>}};
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
credential.id,
|
||||
publicKeyBuffer,
|
||||
counterBigInt,
|
||||
responseObj.response?.transports ? new Set(responseObj.response.transports) : null,
|
||||
name,
|
||||
);
|
||||
}
|
||||
const verified = await verifyWebAuthnRegistrationResponse(ctx, userId, response, expectedChallenge, 'registration');
|
||||
await users.createWebAuthnCredential(
|
||||
userId,
|
||||
verified.credentialId,
|
||||
verified.publicKey,
|
||||
verified.counter,
|
||||
verified.transports,
|
||||
name,
|
||||
storedRpId(ctx, verified.rpId),
|
||||
);
|
||||
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
|
||||
export async function deleteWebAuthnCredential(ctx: ApiContext, userId: UserID, credentialId: string): Promise<void> {
|
||||
const {users, gateway, botMfaMirror} = ctx.services;
|
||||
const credential = await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
if (!credential || credential.supersededBy !== null) {
|
||||
throw new UnknownWebAuthnCredentialError();
|
||||
}
|
||||
await users.deleteWebAuthnCredential(userId, credentialId);
|
||||
const remainingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
const remaining = await users.listWebAuthnCredentials(userId);
|
||||
const remainingCredentials = visibleWebAuthnCredentials(remaining);
|
||||
const orphanedTwins = remaining.filter(
|
||||
(cred) => cred.supersededBy === credentialId || (cred.supersededBy !== null && remainingCredentials.length === 0),
|
||||
);
|
||||
for (const twin of orphanedTwins) {
|
||||
await users.deleteWebAuthnCredential(userId, twin.credentialId);
|
||||
}
|
||||
if (remainingCredentials.length === 0) {
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
if (user.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
@@ -298,37 +378,66 @@ export async function renameWebAuthnCredential(
|
||||
): Promise<void> {
|
||||
const {users} = ctx.services;
|
||||
const credential = await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
if (!credential || credential.supersededBy !== null) {
|
||||
throw new UnknownWebAuthnCredentialError();
|
||||
}
|
||||
await users.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
|
||||
async function dispatchWebAuthnCredentialsUpdate(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {users, gateway} = ctx.services;
|
||||
export async function dispatchWebAuthnCredentialsUpdate(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {users, gateway, config} = ctx.services;
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
await gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'WEBAUTHN_CREDENTIALS_UPDATE',
|
||||
data: credentials.map((cred: WebAuthnCredential) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
})),
|
||||
data: visibleWebAuthnCredentials(credentials).map((cred) =>
|
||||
mapWebAuthnCredentialToResponse(cred, config.auth.passkeys.rpId),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateWebAuthnAuthenticationOptionsDiscoverable(ctx: ApiContext) {
|
||||
export async function generateWebAuthnAuthenticationOptions(
|
||||
ctx: ApiContext,
|
||||
{selection, context, userId, ticket}: WebAuthnAuthenticationOptionsParams,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: ctx.services.config.auth.passkeys.rpId,
|
||||
userVerification: 'required',
|
||||
rpID: selection.rpId,
|
||||
allowCredentials: selection.credentials?.map(toCredentialDescriptor),
|
||||
userVerification: selection.credentials === null ? 'required' : 'discouraged',
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {
|
||||
context,
|
||||
userId,
|
||||
ticket,
|
||||
rpId: selection.rpId,
|
||||
credentialIds: selection.credentials?.map((cred) => cred.credentialId) ?? null,
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'discoverable'});
|
||||
return options;
|
||||
}
|
||||
|
||||
function selectCredentialRpOrThrow(
|
||||
ctx: ApiContext,
|
||||
origin: string | null | undefined,
|
||||
credentials: Array<WebAuthnCredential>,
|
||||
): CredentialRpSelection {
|
||||
const selection = selectCredentialRp(ctx, origin, credentials);
|
||||
if (selection.credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnAuthenticationOptionsDiscoverable(
|
||||
ctx: ApiContext,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
return generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: {rpId: originRpId(ctx, origin), credentials: null},
|
||||
context: 'discoverable',
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnAuthenticationDiscoverable(
|
||||
ctx: ApiContext,
|
||||
response: AuthenticationResponseJSON,
|
||||
@@ -344,29 +453,24 @@ export async function verifyWebAuthnAuthenticationDiscoverable(
|
||||
return users.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
export async function generateWebAuthnAuthenticationOptionsForMfa(ctx: ApiContext, ticket: string) {
|
||||
const {users, cache, config} = ctx.services;
|
||||
export async function generateWebAuthnAuthenticationOptionsForMfa(
|
||||
ctx: ApiContext,
|
||||
ticket: string,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const {users, cache} = ctx.services;
|
||||
const userIdStr = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userIdStr) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
}
|
||||
const userId = createUserID(BigInt(userIdStr));
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
if (credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.auth.passkeys.rpId,
|
||||
allowCredentials: credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
userVerification: 'discouraged',
|
||||
return generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: selectCredentialRpOrThrow(ctx, origin, credentials),
|
||||
context: 'mfa',
|
||||
userId,
|
||||
ticket,
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'mfa', userId, ticket});
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function verifyWebAuthnAuthentication(
|
||||
@@ -376,21 +480,26 @@ export async function verifyWebAuthnAuthentication(
|
||||
expectedChallenge: string,
|
||||
context: WebAuthnChallengeContext = 'mfa',
|
||||
ticket?: string,
|
||||
): Promise<void> {
|
||||
expectedOrigin: Array<string> = ctx.services.config.auth.passkeys.allowedOrigins,
|
||||
): Promise<WebAuthnCredential> {
|
||||
const {users, config} = ctx.services;
|
||||
await consumeWebAuthnChallenge(ctx, expectedChallenge, context, {userId, ticket});
|
||||
const scope = await consumeWebAuthnChallenge(ctx, expectedChallenge, context, {userId, ticket});
|
||||
const credentialId = (response as {id: string}).id;
|
||||
const credential = await users.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
throw new PasskeyAuthenticationFailedError();
|
||||
}
|
||||
if (
|
||||
effectiveRpId(ctx, credential) !== scope.rpId ||
|
||||
(scope.credentialIds !== null && !scope.credentialIds.includes(credentialId))
|
||||
) {
|
||||
throw new PasskeyAuthenticationFailedError();
|
||||
}
|
||||
if (config.dev.testModeEnabled) {
|
||||
await users.updateWebAuthnCredentialCounter(userId, credentialId, credential.counter + 1n);
|
||||
await users.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
return;
|
||||
return credential;
|
||||
}
|
||||
const expectedOrigin = config.auth.passkeys.allowedOrigins;
|
||||
const rpID = config.auth.passkeys.rpId;
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
let publicKeyUint8Array: Uint8Array<ArrayBuffer>;
|
||||
@@ -405,15 +514,12 @@ export async function verifyWebAuthnAuthentication(
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: requiresWebAuthnUserVerification(context),
|
||||
expectedRPID: scope.rpId,
|
||||
requireUserVerification: requiresWebAuthnUserVerification(context, scope),
|
||||
credential: {
|
||||
id: credential.credentialId,
|
||||
...toCredentialDescriptor(credential),
|
||||
publicKey: publicKeyUint8Array,
|
||||
counter: Number(credential.counter),
|
||||
transports: credential.transports
|
||||
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (_error) {
|
||||
@@ -434,31 +540,25 @@ export async function verifyWebAuthnAuthentication(
|
||||
}
|
||||
await users.updateWebAuthnCredentialCounter(userId, credentialId, newCounter);
|
||||
await users.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
return credential;
|
||||
}
|
||||
|
||||
export async function generateWebAuthnOptionsForSudo(ctx: ApiContext, userId: UserID) {
|
||||
const {users, config} = ctx.services;
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
if (credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.auth.passkeys.rpId,
|
||||
allowCredentials: credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
userVerification: 'discouraged',
|
||||
export async function generateWebAuthnOptionsForSudo(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
origin: string | null | undefined,
|
||||
): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const credentials = await ctx.services.users.listWebAuthnCredentials(userId);
|
||||
return generateWebAuthnAuthenticationOptions(ctx, {
|
||||
selection: selectCredentialRpOrThrow(ctx, origin, credentials),
|
||||
context: 'sudo',
|
||||
userId,
|
||||
});
|
||||
await saveWebAuthnChallenge(ctx, options.challenge, {context: 'sudo', userId});
|
||||
return options;
|
||||
}
|
||||
|
||||
const SUDO_MFA_USER_MAX_ATTEMPTS = 10;
|
||||
|
||||
async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
export async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {rateLimit} = ctx.services;
|
||||
const userLimit = await rateLimit.checkLimit({
|
||||
identifier: `sudo-mfa:user:${userId}`,
|
||||
@@ -535,20 +635,33 @@ function webAuthnChallengeCacheKey(challenge: string): string {
|
||||
return `webauthn:challenge:${challenge}`;
|
||||
}
|
||||
|
||||
function requiresWebAuthnUserVerification(context: WebAuthnChallengeContext): boolean {
|
||||
return context === 'discoverable';
|
||||
function requiresWebAuthnUserVerification(context: WebAuthnChallengeContext, scope: WebAuthnChallengeScope): boolean {
|
||||
return context === 'discoverable' || (context === 'bridge' && scope.credentialIds === null);
|
||||
}
|
||||
|
||||
async function saveWebAuthnChallenge(
|
||||
ctx: ApiContext,
|
||||
challenge: string,
|
||||
entry: {context: WebAuthnChallengeContext; userId?: UserID; ticket?: string},
|
||||
entry: {
|
||||
context: WebAuthnChallengeContext;
|
||||
userId?: UserID;
|
||||
ticket?: string;
|
||||
rpId: string;
|
||||
credentialIds: Array<string> | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
await ctx.services.cache.set(
|
||||
webAuthnChallengeCacheKey(challenge),
|
||||
{context: entry.context, userId: entry.userId?.toString(), ticket: entry.ticket},
|
||||
seconds('5 minutes'),
|
||||
);
|
||||
const value: WebAuthnChallengeEntry = {
|
||||
context: entry.context,
|
||||
userId: entry.userId?.toString(),
|
||||
ticket: entry.ticket,
|
||||
rpId: entry.rpId,
|
||||
credentialIds: entry.credentialIds,
|
||||
};
|
||||
await ctx.services.cache.set(webAuthnChallengeCacheKey(challenge), value, seconds('5 minutes'));
|
||||
}
|
||||
|
||||
export async function deleteWebAuthnChallenge(ctx: ApiContext, challenge: string): Promise<void> {
|
||||
await ctx.services.cache.delete(webAuthnChallengeCacheKey(challenge));
|
||||
}
|
||||
|
||||
async function consumeWebAuthnChallenge(
|
||||
@@ -556,10 +669,8 @@ async function consumeWebAuthnChallenge(
|
||||
challenge: string,
|
||||
expectedContext: WebAuthnChallengeContext,
|
||||
{userId, ticket}: {userId?: UserID; ticket?: string} = {},
|
||||
): Promise<void> {
|
||||
const {cache} = ctx.services;
|
||||
const key = webAuthnChallengeCacheKey(challenge);
|
||||
const cached = await cache.get<{context: WebAuthnChallengeContext; userId?: string; ticket?: string}>(key);
|
||||
): Promise<WebAuthnChallengeScope> {
|
||||
const cached = await ctx.services.cache.getAndDelete<WebAuthnChallengeEntry>(webAuthnChallengeCacheKey(challenge));
|
||||
const challengeMatches =
|
||||
cached &&
|
||||
cached.context === expectedContext &&
|
||||
@@ -581,11 +692,14 @@ async function consumeWebAuthnChallenge(
|
||||
);
|
||||
throw createChallengeError(expectedContext);
|
||||
}
|
||||
await cache.delete(key);
|
||||
return {
|
||||
rpId: cached.rpId ?? ctx.services.config.auth.passkeys.rpId,
|
||||
credentialIds: cached.credentialIds ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function createChallengeError(context: WebAuthnChallengeContext) {
|
||||
if (context === 'registration') {
|
||||
if (context === 'registration' || context === 'migration_registration') {
|
||||
return new InvalidWebAuthnCredentialError();
|
||||
}
|
||||
return new PasskeyAuthenticationFailedError();
|
||||
|
||||
@@ -280,21 +280,18 @@ export class AuthRequestService {
|
||||
return {completed: false};
|
||||
}
|
||||
|
||||
async getWebAuthnAuthenticationOptions() {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsDiscoverable(this.apiContext);
|
||||
async getWebAuthnAuthenticationOptions(origin: string | undefined) {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsDiscoverable(this.apiContext, origin);
|
||||
}
|
||||
|
||||
async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) {
|
||||
const user = await AuthMfa.verifyWebAuthnAuthenticationDiscoverable(this.apiContext, data.response, data.challenge);
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
|
||||
});
|
||||
const [token] = await AuthLogin.createLoginSession(this.apiContext, user, request);
|
||||
return {token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
|
||||
}
|
||||
|
||||
async getWebAuthnMfaOptions({ticket}: MfaTicketRequest) {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsForMfa(this.apiContext, ticket);
|
||||
async getWebAuthnMfaOptions({ticket}: MfaTicketRequest, origin: string | undefined) {
|
||||
return AuthMfa.generateWebAuthnAuthenticationOptionsForMfa(this.apiContext, ticket, origin);
|
||||
}
|
||||
|
||||
async loginMfaWebAuthn({data, request}: AuthWebAuthnMfaRequest): Promise<AuthTokenWithUserIdResponse> {
|
||||
|
||||
@@ -43,7 +43,6 @@ async function revokeSessionTargets(
|
||||
scope === 'all'
|
||||
? users.deleteAllPushSubscriptions(userId)
|
||||
: users.deletePushSubscriptionsForAuthSessions(userId, sessionIdHashes, {deleteUnboundSubscriptions: true}),
|
||||
() => gateway.invalidatePushSubscriptions({userId}),
|
||||
];
|
||||
if (scope === 'selected' || targets.length > 0) {
|
||||
steps.push(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
@@ -8,7 +9,9 @@ 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 {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
@@ -29,18 +32,30 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, voiceConfig, domainMigrationConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
const [delivery, voiceConfig, domainMigrationConfig, altchaCaptchaConfig, profileTimezoneConfig] =
|
||||
await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getProfileTimezoneConfig(),
|
||||
]);
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id.toString();
|
||||
const targeting = await resolveExperimentTargeting(user, [
|
||||
voiceConfig,
|
||||
domainMigrationConfig,
|
||||
altchaCaptchaConfig,
|
||||
profileTimezoneConfig,
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId),
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId, targeting),
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
|
||||
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId, targeting),
|
||||
profile_timezone: resolveProfileTimezoneAssignment(profileTimezoneConfig, userId, targeting),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {ExperimentTargeting} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
|
||||
interface TargetableExperimentConfig {
|
||||
readonly enabled: boolean;
|
||||
readonly included_guild_ids: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
const NO_GUILDS: ReadonlySet<string> = new Set();
|
||||
|
||||
export const ANONYMOUS_EXPERIMENT_TARGETING: ExperimentTargeting = {
|
||||
memberGuildIds: NO_GUILDS,
|
||||
premium: false,
|
||||
};
|
||||
|
||||
export async function resolveExperimentTargeting(
|
||||
user: User,
|
||||
configs: ReadonlyArray<TargetableExperimentConfig>,
|
||||
): Promise<ExperimentTargeting> {
|
||||
const needsGuilds = configs.some((config) => config.enabled && config.included_guild_ids.length > 0);
|
||||
const memberGuildIds = needsGuilds
|
||||
? new Set((await getUserRepository().getUserGuildIds(user.id)).map((guildId) => guildId.toString()))
|
||||
: NO_GUILDS;
|
||||
return {memberGuildIds, premium: !user.isBot && user.isPremium()};
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
|
||||
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} from '@app/api/test/TestRequestBuilder';
|
||||
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
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,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
INERT_PROFILE_TIMEZONE_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
@@ -57,6 +68,8 @@ describe('GET /experiments', () => {
|
||||
assignments: {
|
||||
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
|
||||
profile_timezone: INERT_PROFILE_TIMEZONE_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -134,6 +147,215 @@ 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('resolves the profile timezone caller through the allowlist and the exclusion list', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [targeted.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.profile_timezone).toEqual({enabled: true});
|
||||
|
||||
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(excludedBody.assignments.profile_timezone).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('bumps the profile timezone 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<{profile_timezone: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({profile_timezone: {enabled: true, included_user_ids: [admin.userId]}})
|
||||
.execute();
|
||||
expect(afterFirst.profile_timezone).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{
|
||||
profile_timezone: {config_version: number; rollout_basis_points: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({profile_timezone: {rollout_basis_points: 2500}})
|
||||
.execute();
|
||||
expect(afterSecond.profile_timezone).toMatchObject({config_version: 2, rollout_basis_points: 2500});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.profile_timezone).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const outsider = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Experiment Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
const repository = getInstanceConfigRepository();
|
||||
await repository.setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
await repository.setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
await repository.setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
await repository.setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
|
||||
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(memberBody.assignments.voice_noise_suppression).toMatchObject({user_targeted: true, source: 'user_rule'});
|
||||
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.profile_timezone).toEqual({enabled: true});
|
||||
|
||||
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(outsiderBody.assignments.voice_noise_suppression).toMatchObject({user_targeted: false, source: null});
|
||||
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.altcha_captcha).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.profile_timezone).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
|
||||
const subscriber = await createTestAccount(harness);
|
||||
const visionary = await createTestAccount(harness);
|
||||
const free = await createTestAccount(harness);
|
||||
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
|
||||
await grantPremium(harness, visionary.userId, UserPremiumTypes.LIFETIME);
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
include_premium_users: true,
|
||||
});
|
||||
for (const [account, expected] of [
|
||||
[subscriber, true],
|
||||
[visionary, true],
|
||||
[free, false],
|
||||
] as const) {
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.profile_timezone).toEqual({enabled: expected});
|
||||
}
|
||||
});
|
||||
|
||||
it('stores the guild ids and premium switch an admin sets for each experiment', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
const guildIds = ['1500000000000000001', '1500000000000000002'];
|
||||
const body = await createBuilder<
|
||||
Record<
|
||||
'voice_noise_suppression' | 'domain_migration' | 'altcha_captcha' | 'profile_timezone',
|
||||
{included_guild_ids: Array<string>; include_premium_users: boolean}
|
||||
>
|
||||
>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({
|
||||
voice_noise_suppression: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
altcha_captcha: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
profile_timezone: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
})
|
||||
.execute();
|
||||
expect(body.voice_noise_suppression.included_guild_ids).toEqual(guildIds);
|
||||
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
|
||||
expect(body.altcha_captcha.included_guild_ids).toEqual(guildIds);
|
||||
expect(body.profile_timezone.included_guild_ids).toEqual(guildIds);
|
||||
for (const section of [
|
||||
body.voice_noise_suppression,
|
||||
body.domain_migration,
|
||||
body.altcha_captcha,
|
||||
body.profile_timezone,
|
||||
]) {
|
||||
expect(section.include_premium_users).toBe(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({
|
||||
|
||||
@@ -37,7 +37,6 @@ import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMe
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const PUSH_BADGE_COUNT_BATCH_SIZE = 100;
|
||||
const USER_PERMISSIONS_BATCH_SIZE = 100;
|
||||
|
||||
const GATEWAY_ERROR_TO_DOMAIN_ERROR: Record<string, () => Error> = {
|
||||
@@ -67,18 +66,6 @@ interface DispatchPresenceParams {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface InvalidatePushBadgeCountParams {
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
interface InvalidatePushBadgeCountsParams {
|
||||
userIds: Array<UserID>;
|
||||
}
|
||||
|
||||
interface InvalidatePushSubscriptionsParams {
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
interface ClearPushChannelNotificationsParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
@@ -287,8 +274,6 @@ export class GatewayService {
|
||||
private readonly MAX_BATCH_CONCURRENCY = 50;
|
||||
private readonly PENDING_REQUEST_TIMEOUT_MS = ms('30 seconds');
|
||||
private readonly AUTH_CONTEXT_FALLBACK_MS = ms('5 minutes');
|
||||
private readonly BADGE_COUNTS_FALLBACK_MS = ms('5 minutes');
|
||||
private badgeCountsUnsupportedUntil = 0;
|
||||
|
||||
constructor() {
|
||||
this.rpcClient = GatewayRpcClient.getInstance();
|
||||
@@ -704,48 +689,6 @@ export class GatewayService {
|
||||
});
|
||||
}
|
||||
|
||||
async invalidatePushBadgeCount({userId}: InvalidatePushBadgeCountParams): Promise<void> {
|
||||
await this.call('push.invalidate_badge_count', {
|
||||
user_id: userId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async invalidatePushBadgeCounts({userIds}: InvalidatePushBadgeCountsParams): Promise<void> {
|
||||
if (Date.now() < this.badgeCountsUnsupportedUntil) {
|
||||
await this.invalidatePushBadgeCountsIndividually(userIds);
|
||||
return;
|
||||
}
|
||||
const batches: Array<Array<UserID>> = [];
|
||||
for (let index = 0; index < userIds.length; index += PUSH_BADGE_COUNT_BATCH_SIZE) {
|
||||
batches.push(userIds.slice(index, index + PUSH_BADGE_COUNT_BATCH_SIZE));
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
batches.map((batch) =>
|
||||
this.call('push.invalidate_badge_counts', {user_ids: batch.map((userId) => userId.toString())}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const transformedError = this.transformGatewayError(error);
|
||||
if (!this.isAuthContextUnsupportedError(transformedError)) {
|
||||
throw transformedError;
|
||||
}
|
||||
this.badgeCountsUnsupportedUntil = Date.now() + this.BADGE_COUNTS_FALLBACK_MS;
|
||||
Logger.warn({error}, '[gateway-rpc] push.invalidate_badge_counts unavailable, falling back to per-user calls');
|
||||
await this.invalidatePushBadgeCountsIndividually(userIds);
|
||||
}
|
||||
}
|
||||
|
||||
private async invalidatePushBadgeCountsIndividually(userIds: ReadonlyArray<UserID>): Promise<void> {
|
||||
await Promise.all(userIds.map((userId) => this.invalidatePushBadgeCount({userId})));
|
||||
}
|
||||
|
||||
async invalidatePushSubscriptions({userId}: InvalidatePushSubscriptionsParams): Promise<void> {
|
||||
await this.call('push.invalidate_subscriptions', {
|
||||
user_id: userId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async clearPushChannelNotifications({
|
||||
userId,
|
||||
channelId,
|
||||
|
||||
@@ -292,12 +292,6 @@ export abstract class IGatewayService {
|
||||
|
||||
abstract dispatchPresence(params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void>;
|
||||
|
||||
abstract invalidatePushBadgeCount(params: {userId: UserID}): Promise<void>;
|
||||
|
||||
abstract invalidatePushBadgeCounts(params: {userIds: Array<UserID>}): Promise<void>;
|
||||
|
||||
abstract invalidatePushSubscriptions(params: {userId: UserID}): Promise<void>;
|
||||
|
||||
abstract clearPushChannelNotifications(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
|
||||
@@ -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,
|
||||
@@ -37,9 +41,15 @@ import {
|
||||
GatewayRolloutConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {
|
||||
type PushServiceDeliveryConfig,
|
||||
PushServiceDeliveryConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
type ProfileTimezoneConfig,
|
||||
ProfileTimezoneConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import {
|
||||
type LegacyPushServiceDeliveryWire,
|
||||
type PushRelayConfig,
|
||||
PushRelayConfigSchema,
|
||||
toLegacyPushServiceDeliveryWire,
|
||||
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
VoiceNoiseSuppressionConfigSchema,
|
||||
@@ -66,8 +76,10 @@ import {z} from 'zod';
|
||||
|
||||
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 PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
|
||||
const PROFILE_TIMEZONE_CONFIG_KEY = 'profile_timezone_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
@@ -374,8 +386,10 @@ type StoredConfigSection =
|
||||
| 'app public'
|
||||
| 'gateway rollout'
|
||||
| 'voice noise suppression'
|
||||
| 'push service delivery'
|
||||
| 'push relay'
|
||||
| 'domain migration'
|
||||
| 'altcha captcha'
|
||||
| 'profile timezone'
|
||||
| 'experiment delivery'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
@@ -514,14 +528,39 @@ function parseStoredVoiceNoiseSuppressionConfig(raw: string | null): VoiceNoiseS
|
||||
return parseStoredConfigOrDefault(VoiceNoiseSuppressionConfigSchema, raw, 'voice noise suppression');
|
||||
}
|
||||
|
||||
function parseStoredPushServiceDeliveryConfig(raw: string | null): PushServiceDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(PushServiceDeliveryConfigSchema, raw, 'push service delivery');
|
||||
const StoredPushRelayConfigSchema = PushRelayConfigSchema.extend({
|
||||
config_version: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
function parseStoredPushRelayConfig(raw: string | null): LegacyPushServiceDeliveryWire {
|
||||
const {config_version, ...config} = salvageStoredConfig(
|
||||
StoredPushRelayConfigSchema,
|
||||
readStoredConfigValue(raw, 'push relay'),
|
||||
'push relay',
|
||||
);
|
||||
return toLegacyPushServiceDeliveryWire(config, config_version);
|
||||
}
|
||||
|
||||
function toPushRelayConfig(wire: LegacyPushServiceDeliveryWire): PushRelayConfig {
|
||||
return {
|
||||
relay_consent_accepted: wire.relay_consent_accepted,
|
||||
relay_consent_accepted_at: wire.relay_consent_accepted_at,
|
||||
relay_consent_accepted_by: wire.relay_consent_accepted_by,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationConfig {
|
||||
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
|
||||
}
|
||||
|
||||
function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig {
|
||||
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
|
||||
}
|
||||
|
||||
function parseStoredProfileTimezoneConfig(raw: string | null): ProfileTimezoneConfig {
|
||||
return parseStoredConfigOrDefault(ProfileTimezoneConfigSchema, raw, 'profile timezone');
|
||||
}
|
||||
|
||||
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
|
||||
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
|
||||
}
|
||||
@@ -1169,8 +1208,10 @@ export class InstanceConfigRepository {
|
||||
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
|
||||
);
|
||||
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
|
||||
parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null);
|
||||
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
|
||||
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
|
||||
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
|
||||
parseStoredProfileTimezoneConfig(snapshot.get(PROFILE_TIMEZONE_CONFIG_KEY) ?? null);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
@@ -1267,21 +1308,21 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getPushServiceDeliveryConfig(): Promise<PushServiceDeliveryConfig> {
|
||||
const raw = await this.getConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredPushServiceDeliveryConfig(raw);
|
||||
async getLegacyPushServiceDeliveryWire(): Promise<LegacyPushServiceDeliveryWire> {
|
||||
const raw = await this.getConfig(PUSH_RELAY_CONFIG_KEY);
|
||||
return parseStoredPushRelayConfig(raw);
|
||||
}
|
||||
|
||||
updatePushServiceDeliveryConfig(
|
||||
update: (current: PushServiceDeliveryConfig) => PushServiceDeliveryConfig,
|
||||
): Promise<PushServiceDeliveryConfig> {
|
||||
return this.updateStoredConfig(PUSH_SERVICE_DELIVERY_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
PushServiceDeliveryConfigSchema,
|
||||
update(parseStoredPushServiceDeliveryConfig(raw)),
|
||||
'push service delivery',
|
||||
),
|
||||
);
|
||||
async getPushRelayConfig(): Promise<PushRelayConfig> {
|
||||
return toPushRelayConfig(await this.getLegacyPushServiceDeliveryWire());
|
||||
}
|
||||
|
||||
updatePushRelayConfig(update: (current: PushRelayConfig) => PushRelayConfig): Promise<LegacyPushServiceDeliveryWire> {
|
||||
return this.updateStoredConfig(PUSH_RELAY_CONFIG_KEY, (raw) => {
|
||||
const current = parseStoredPushRelayConfig(raw);
|
||||
const next = validateStoredConfig(PushRelayConfigSchema, update(toPushRelayConfig(current)), 'push relay');
|
||||
return toLegacyPushServiceDeliveryWire(next, current.config_version + 1);
|
||||
});
|
||||
}
|
||||
|
||||
async getDomainMigrationConfig(): Promise<DomainMigrationConfig> {
|
||||
@@ -1305,6 +1346,44 @@ 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 getProfileTimezoneConfig(): Promise<ProfileTimezoneConfig> {
|
||||
const raw = await this.getConfig(PROFILE_TIMEZONE_CONFIG_KEY);
|
||||
return parseStoredProfileTimezoneConfig(raw);
|
||||
}
|
||||
|
||||
async setProfileTimezoneConfig(config: ProfileTimezoneConfig): Promise<void> {
|
||||
await this.updateProfileTimezoneConfig(() => config);
|
||||
}
|
||||
|
||||
updateProfileTimezoneConfig(
|
||||
update: (current: ProfileTimezoneConfig) => ProfileTimezoneConfig,
|
||||
): Promise<ProfileTimezoneConfig> {
|
||||
return this.updateStoredConfig(PROFILE_TIMEZONE_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(
|
||||
ProfileTimezoneConfigSchema,
|
||||
update(parseStoredProfileTimezoneConfig(raw)),
|
||||
'profile timezone',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
|
||||
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredExperimentDeliveryConfig(raw);
|
||||
|
||||
+5
-5
@@ -1,21 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {PushServiceDeliveryConfig} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import type {LegacyPushServiceDeliveryWire} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
|
||||
const PUSH_SERVICE_DELIVERY_CONFIG_NATS_SUBJECT = 'config.push.delivery';
|
||||
|
||||
interface PushServiceDeliveryConfigNatsMessage {
|
||||
type: 'push_service_delivery_config';
|
||||
config: PushServiceDeliveryConfig;
|
||||
config: LegacyPushServiceDeliveryWire;
|
||||
}
|
||||
|
||||
export class PushServiceDeliveryConfigPublisher {
|
||||
export class PushRelayConfigPublisher {
|
||||
constructor(private readonly connectionManager: INatsConnectionManager) {}
|
||||
|
||||
async publish(config: PushServiceDeliveryConfig): Promise<void> {
|
||||
async publish(config: LegacyPushServiceDeliveryWire): Promise<void> {
|
||||
if (this.connectionManager.isClosed()) {
|
||||
await this.connectionManager.connect();
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHmac} from 'node:crypto';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ANONYMOUS_EXPERIMENT_TARGETING, resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
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 +13,44 @@ 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();
|
||||
const targeting = user ? await resolveExperimentTargeting(user, [config]) : ANONYMOUS_EXPERIMENT_TARGETING;
|
||||
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null, targeting)) return null;
|
||||
return createAltchaProvider(config);
|
||||
}
|
||||
|
||||
function resolveProviderSecret(
|
||||
config: InstanceCaptchaEffectiveConfig,
|
||||
provider: InstanceCaptchaProvider,
|
||||
@@ -58,11 +94,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 +116,7 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
|
||||
}) ?? undefined,
|
||||
});
|
||||
if (!isValid) {
|
||||
throw new InvalidCaptchaError();
|
||||
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClie
|
||||
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
|
||||
import {GatewayRolloutConfigPublisher} from '@app/api/instance/GatewayRolloutConfigPublisher';
|
||||
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {PushServiceDeliveryConfigPublisher} from '@app/api/instance/PushServiceDeliveryConfigPublisher';
|
||||
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
|
||||
import {InviteRepository} from '@app/api/invite/InviteRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
@@ -157,13 +157,13 @@ export const getGatewayRolloutConfigPublisher = singleton(
|
||||
),
|
||||
);
|
||||
|
||||
export const getPushServiceDeliveryConfigPublisher = singleton(
|
||||
export const getPushRelayConfigPublisher = singleton(
|
||||
() =>
|
||||
new PushServiceDeliveryConfigPublisher(
|
||||
new PushRelayConfigPublisher(
|
||||
new NatsConnectionManager({
|
||||
url: Config.nats.coreUrl,
|
||||
token: Config.nats.authToken || undefined,
|
||||
name: 'fluxer-api-push-service-delivery-config',
|
||||
name: 'fluxer-api-push-relay-config',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {DEFAULT_ALTCHA_CAPTCHA_CONFIG} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -25,6 +26,7 @@ function createHarness(
|
||||
): (headers: Record<string, string>) => Promise<Response> {
|
||||
const repository = {
|
||||
getEffectiveCaptchaConfig: async () => captcha,
|
||||
getAltchaCaptchaConfig: async () => DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
} as unknown as InstanceConfigRepository;
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ export class WebAuthnCredential {
|
||||
readonly createdAt: Date;
|
||||
readonly lastUsedAt: Date | null;
|
||||
readonly version: number;
|
||||
readonly rpId: string | null;
|
||||
readonly supersededBy: string | null;
|
||||
|
||||
constructor(row: WebAuthnCredentialRow) {
|
||||
this.credentialId = row.credential_id;
|
||||
@@ -22,6 +24,8 @@ export class WebAuthnCredential {
|
||||
this.createdAt = row.created_at;
|
||||
this.lastUsedAt = row.last_used_at ?? null;
|
||||
this.version = row.version;
|
||||
this.rpId = row.rp_id ?? null;
|
||||
this.supersededBy = row.superseded_by ?? null;
|
||||
}
|
||||
|
||||
toRow(userId: UserID): WebAuthnCredentialRow {
|
||||
@@ -35,6 +39,8 @@ export class WebAuthnCredential {
|
||||
created_at: this.createdAt,
|
||||
last_used_at: this.lastUsedAt,
|
||||
version: this.version,
|
||||
rp_id: this.rpId,
|
||||
superseded_by: this.supersededBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1053,6 +1053,292 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/passkey-bridge": {
|
||||
"post": {
|
||||
"operationId": "start_passkey_bridge_login",
|
||||
"summary": "Start passkey bridge sign in",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeStartResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Start a sign in or two-factor ceremony for a passkey that belongs to the paired first-party origin. Only available on the official instance from the new origin.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeLoginStartRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/passkey-bridge/{ceremony_id}/cancel": {
|
||||
"post": {
|
||||
"operationId": "cancel_passkey_bridge",
|
||||
"summary": "Cancel passkey bridge",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeFinishResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Cancel a passkey bridge ceremony that has not completed.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ceremony_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
},
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/passkey-bridge/{ceremony_id}/complete": {
|
||||
"post": {
|
||||
"operationId": "complete_passkey_bridge",
|
||||
"summary": "Complete passkey bridge",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeFinishResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Verify the WebAuthn response for a pending passkey bridge ceremony. A failed verification leaves the ceremony pending so it can be retried.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ceremony_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
},
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeCompleteRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/passkey-bridge/{ceremony_id}/options": {
|
||||
"post": {
|
||||
"operationId": "get_passkey_bridge_options",
|
||||
"summary": "Get passkey bridge options",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeOptionsResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Issue WebAuthn authentication options for a pending passkey bridge ceremony. The request must come from the origin that runs the ceremony.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ceremony_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
},
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/passkey-bridge/{ceremony_id}/redeem": {
|
||||
"post": {
|
||||
"operationId": "redeem_passkey_bridge_login",
|
||||
"summary": "Redeem passkey bridge sign in",
|
||||
"tags": ["Auth"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeLoginRedeemResponse"}}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a finished sign in or two-factor passkey bridge ceremony once. Requires the nonce kept by the starting page and the completion code handed back when the ceremony finished.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ceremony_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
},
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeRedeemRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"operationId": "register_account",
|
||||
@@ -17029,6 +17315,164 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/mfa/webauthn/migration": {
|
||||
"get": {
|
||||
"operationId": "get_webauthn_migration",
|
||||
"summary": "Get pending passkey update",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyMigrationResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Return the passkey this session can update to the new domain after using it within the last five minutes, or null.",
|
||||
"security": [{"sessionToken": []}]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "complete_webauthn_migration",
|
||||
"summary": "Complete passkey update",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"204": {"description": "No Content"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Register the replacement passkey under the name of the pending one. The old passkey stops appearing in lists and is removed together with its replacement.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyMigrationCompleteRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/mfa/webauthn/migration/registration-options": {
|
||||
"post": {
|
||||
"operationId": "get_webauthn_migration_registration_options",
|
||||
"summary": "Get passkey update registration options",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebAuthnChallengeResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Generate registration options for the passkey that replaces the pending one. Requires a pending passkey update for this session.",
|
||||
"security": [{"sessionToken": []}]
|
||||
}
|
||||
},
|
||||
"/users/@me/mfa/webauthn/two-factor": {
|
||||
"put": {
|
||||
"operationId": "set_webauthn_two_factor",
|
||||
@@ -17489,6 +17933,135 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/passkey-bridge": {
|
||||
"post": {
|
||||
"operationId": "start_passkey_bridge_sudo",
|
||||
"summary": "Start passkey bridge sudo verification",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeStartResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Start a sudo verification ceremony for a passkey that belongs to the paired first-party origin. Only available on the official instance from the new origin.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeSudoStartRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/passkey-bridge/{ceremony_id}/redeem": {
|
||||
"post": {
|
||||
"operationId": "redeem_passkey_bridge_sudo",
|
||||
"summary": "Redeem passkey bridge sudo verification",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeSudoRedeemResponse"}}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a finished sudo passkey bridge ceremony once for a sudo mode token. Requires the nonce kept by the starting page and the completion code handed back when the ceremony finished.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ceremony_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
},
|
||||
"description": "Identifier of the passkey ceremony"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasskeyBridgeRedeemRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/password-change/complete": {
|
||||
"post": {
|
||||
"operationId": "complete_password_change",
|
||||
@@ -21831,14 +22404,8 @@
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
"description": "The user-selected accent color as an integer"
|
||||
},
|
||||
"timezone": {
|
||||
"description": "The IANA timezone identifier saved by the user. Omitted unless the user has staff access.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"timezone_privacy_flags": {
|
||||
"description": "Bitfield controlling who can see the profile timezone. Omitted unless the user has staff access.",
|
||||
"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"
|
||||
},
|
||||
"timezone": {"description": "The IANA timezone identifier saved by the user", "type": ["string", "null"]},
|
||||
"timezone_privacy_flags": {"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"},
|
||||
"banner": {"description": "The hash of the user profile banner image", "type": ["string", "null"]},
|
||||
"banner_color": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
@@ -22821,6 +23388,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 +23632,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": {
|
||||
@@ -24466,11 +25115,11 @@
|
||||
"anyOf": [{"$ref": "#/components/schemas/ColorType"}, {"type": "null"}]
|
||||
},
|
||||
"timezone": {
|
||||
"description": "Staff-only IANA timezone identifier saved for profile local time. Ignored for non-staff users.",
|
||||
"description": "IANA timezone identifier saved for profile local time. Ignored unless the profile_timezone experiment serves the user.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"timezone_privacy_flags": {
|
||||
"description": "Staff-only bitfield controlling who can see the profile timezone. Ignored for non-staff users.",
|
||||
"description": "Bitfield controlling who can see the profile timezone. Ignored unless the profile_timezone experiment serves the user.",
|
||||
"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"
|
||||
},
|
||||
"premium_badge_hidden": {"type": "boolean", "description": "Whether to hide the premium badge"},
|
||||
@@ -27298,7 +27947,9 @@
|
||||
"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"},
|
||||
"profile_timezone": {"$ref": "#/components/schemas/ProfileTimezoneAssignmentResponse"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -28511,6 +29162,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 +30055,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 +31625,18 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
|
||||
"ProfileTimezoneAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AltchaCaptchaAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
@@ -33666,9 +34422,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 +34473,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')},
|
||||
|
||||
@@ -15,53 +15,6 @@ import {ReadStateService} from '@app/api/read_state/ReadStateService';
|
||||
import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('ReadStateService.bulkIncrementMentionCounts', () => {
|
||||
it('invalidates badge counts for touched users in a single bulk call', async () => {
|
||||
const channelId = createChannelID(2n);
|
||||
const messageId = createMessageID(3n);
|
||||
const touched: Array<{userId: UserID; channelId: ChannelID}> = [
|
||||
{userId: createUserID(10n), channelId},
|
||||
{userId: createUserID(11n), channelId},
|
||||
{userId: createUserID(10n), channelId: createChannelID(4n)},
|
||||
];
|
||||
const repository = {
|
||||
bulkIncrementMentionCounts: vi.fn().mockResolvedValue(touched),
|
||||
} as unknown as IReadStateRepository;
|
||||
const invalidatePushBadgeCounts = vi.fn().mockResolvedValue(undefined);
|
||||
const invalidatePushBadgeCount = vi.fn().mockResolvedValue(undefined);
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCounts,
|
||||
invalidatePushBadgeCount,
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
await service.bulkIncrementMentionCounts([
|
||||
{userId: createUserID(10n), channelId, messageId},
|
||||
{userId: createUserID(11n), channelId, messageId},
|
||||
{userId: createUserID(12n), channelId, messageId},
|
||||
]);
|
||||
|
||||
expect(invalidatePushBadgeCount).not.toHaveBeenCalled();
|
||||
expect(invalidatePushBadgeCounts).toHaveBeenCalledTimes(1);
|
||||
expect(invalidatePushBadgeCounts).toHaveBeenCalledWith({userIds: [createUserID(10n), createUserID(11n)]});
|
||||
});
|
||||
|
||||
it('skips the bulk call when no read state was touched', async () => {
|
||||
const repository = {
|
||||
bulkIncrementMentionCounts: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as IReadStateRepository;
|
||||
const invalidatePushBadgeCounts = vi.fn().mockResolvedValue(undefined);
|
||||
const gatewayService = {invalidatePushBadgeCounts} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
await service.bulkIncrementMentionCounts([
|
||||
{userId: createUserID(10n), channelId: createChannelID(2n), messageId: createMessageID(3n)},
|
||||
]);
|
||||
|
||||
expect(invalidatePushBadgeCounts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const USER_ID = createUserID(20n);
|
||||
const CHANNEL_ID = createChannelID(21n);
|
||||
const MESSAGE_ID = createMessageID(22n);
|
||||
@@ -78,7 +31,7 @@ function makeReadState(channelId: ChannelID, messageId: MessageID, mentionCount
|
||||
}
|
||||
|
||||
describe('ReadStateService gateway side effects after the write', () => {
|
||||
it('returns the committed read state when the badge invalidation fails', async () => {
|
||||
it('returns the committed read state when clearing push notifications fails', async () => {
|
||||
const stored: Array<{channelId: ChannelID; messageId: MessageID}> = [];
|
||||
const repository = {
|
||||
upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => {
|
||||
@@ -87,8 +40,7 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
}),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
clearPushChannelNotifications: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
dispatchPresence: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
@@ -113,7 +65,6 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockResolvedValue(undefined),
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
dispatchPresence: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
} as unknown as IGatewayService;
|
||||
@@ -138,7 +89,6 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
}),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockResolvedValue(undefined),
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
dispatchPresence: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
} as unknown as IGatewayService;
|
||||
@@ -156,42 +106,13 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
expect(stored).toEqual(['21', '23']);
|
||||
});
|
||||
|
||||
it('deletes the read state when the badge invalidation fails', async () => {
|
||||
const deleteReadState = vi.fn().mockResolvedValue(undefined);
|
||||
const repository = {deleteReadState} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
await expect(service.deleteReadState({userId: USER_ID, channelId: CHANNEL_ID})).resolves.toBeUndefined();
|
||||
|
||||
expect(deleteReadState).toHaveBeenCalledWith(USER_ID, CHANNEL_ID);
|
||||
});
|
||||
|
||||
it('increments the mention count when the badge invalidation fails', async () => {
|
||||
const incrementReadStateMentions = vi.fn().mockResolvedValue(makeReadState(CHANNEL_ID, MESSAGE_ID, 1));
|
||||
const repository = {incrementReadStateMentions} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
await expect(
|
||||
service.incrementMentionCount({userId: USER_ID, channelId: CHANNEL_ID, messageId: MESSAGE_ID}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(incrementReadStateMentions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the bulk acknowledged states when the badge invalidation fails', async () => {
|
||||
it('returns the bulk acknowledged states when clearing push notifications fails', async () => {
|
||||
const updated = [makeReadState(CHANNEL_ID, MESSAGE_ID)];
|
||||
const repository = {
|
||||
bulkAckMessages: vi.fn().mockResolvedValue(updated),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
invalidatePushBadgeCount: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
clearPushChannelNotifications: vi.fn().mockRejectedValue(new BadGatewayError()),
|
||||
dispatchPresence: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as IGatewayService;
|
||||
const service = new ReadStateService(repository, gatewayService);
|
||||
|
||||
@@ -34,7 +34,6 @@ export class ReadStateService {
|
||||
undefined,
|
||||
manual ?? false,
|
||||
);
|
||||
await this.invalidatePushBadgeCount(userId);
|
||||
if (!silent) {
|
||||
await this.clearPushChannelNotifications({userId, channelId, messageId});
|
||||
}
|
||||
@@ -115,7 +114,6 @@ export class ReadStateService {
|
||||
try {
|
||||
const updatedReadStates = await this.repository.bulkAckMessages(userId, readStates);
|
||||
const readStatesByChannel = new Map(updatedReadStates.map((readState) => [readState.channelId, readState]));
|
||||
await this.invalidatePushBadgeCount(userId);
|
||||
await Promise.all(
|
||||
readStates.map(({channelId, messageId}) =>
|
||||
Promise.all([
|
||||
@@ -145,7 +143,6 @@ export class ReadStateService {
|
||||
|
||||
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
await this.repository.deleteReadState(userId, channelId);
|
||||
await this.invalidatePushBadgeCount(userId);
|
||||
}
|
||||
|
||||
async incrementMentionCount({
|
||||
@@ -157,11 +154,7 @@ export class ReadStateService {
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<void> {
|
||||
const readState = await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1);
|
||||
if (readState == null) {
|
||||
return;
|
||||
}
|
||||
await this.invalidatePushBadgeCount(userId);
|
||||
await this.repository.incrementReadStateMentions(userId, channelId, messageId, 1);
|
||||
}
|
||||
|
||||
async bulkIncrementMentionCounts(
|
||||
@@ -175,15 +168,7 @@ export class ReadStateService {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const appliedUpdates = await this.repository.bulkIncrementMentionCounts(updates);
|
||||
const uniqueUserIds = Array.from(new Set(appliedUpdates.map((update) => update.userId)));
|
||||
if (uniqueUserIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.gatewayService.invalidatePushBadgeCounts({userIds: uniqueUserIds}).catch((error) => {
|
||||
Logger.error({userCount: uniqueUserIds.length, error}, 'Failed to invalidate push badge counts');
|
||||
return null;
|
||||
});
|
||||
await this.repository.bulkIncrementMentionCounts(updates);
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Bulk increment mention counts failed');
|
||||
throw error;
|
||||
@@ -196,13 +181,6 @@ export class ReadStateService {
|
||||
await this.dispatchPinsAck({userId, channelId, timestamp});
|
||||
}
|
||||
|
||||
private async invalidatePushBadgeCount(userId: UserID): Promise<void> {
|
||||
await this.gatewayService.invalidatePushBadgeCount({userId}).catch((error) => {
|
||||
Logger.error({userId: userId.toString(), error}, 'Failed to invalidate push badge count');
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchMessageAck(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
|
||||
@@ -1,34 +1,69 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {parseIpBanEntry, tryParseSingleIp} from '@app/api/utils/IpRangeUtils';
|
||||
import type {IpAddressFamily} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
|
||||
let exemptDecisionKeys: ReadonlySet<string> | null = null;
|
||||
interface ExemptRange {
|
||||
family: IpAddressFamily;
|
||||
start: bigint;
|
||||
end: bigint;
|
||||
}
|
||||
|
||||
function getExemptDecisionKeys(): ReadonlySet<string> {
|
||||
if (exemptDecisionKeys) {
|
||||
return exemptDecisionKeys;
|
||||
interface IpBanExemptions {
|
||||
decisionKeys: ReadonlySet<string>;
|
||||
ranges: ReadonlyArray<ExemptRange>;
|
||||
}
|
||||
|
||||
let exemptions: IpBanExemptions | null = null;
|
||||
|
||||
function getExemptions(): IpBanExemptions {
|
||||
if (exemptions) {
|
||||
return exemptions;
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
for (const ip of Config.ipBanExemptIps) {
|
||||
const key = getSameIpDecisionKey(ip);
|
||||
if (!key) {
|
||||
throw new Error(`Invalid IP ban exemption in API config: ${ip}`);
|
||||
const decisionKeys = new Set<string>();
|
||||
const ranges: Array<ExemptRange> = [];
|
||||
for (const entry of Config.ipBanExemptIps) {
|
||||
if (entry.includes('/')) {
|
||||
const range = parseIpBanEntry(entry);
|
||||
if (range?.type !== 'range') {
|
||||
throw new Error(`Invalid IP ban exemption in API config: ${entry}`);
|
||||
}
|
||||
ranges.push({family: range.family, start: range.start, end: range.end});
|
||||
continue;
|
||||
}
|
||||
keys.add(key);
|
||||
const key = getSameIpDecisionKey(entry);
|
||||
if (!key) {
|
||||
throw new Error(`Invalid IP ban exemption in API config: ${entry}`);
|
||||
}
|
||||
decisionKeys.add(key);
|
||||
}
|
||||
exemptDecisionKeys = keys;
|
||||
return keys;
|
||||
exemptions = {decisionKeys, ranges};
|
||||
return exemptions;
|
||||
}
|
||||
|
||||
export function isIpBanExempt(ip: string | null | undefined): boolean {
|
||||
if (!ip) {
|
||||
return false;
|
||||
}
|
||||
const {decisionKeys, ranges} = getExemptions();
|
||||
const key = getSameIpDecisionKey(ip);
|
||||
return key !== null && getExemptDecisionKeys().has(key);
|
||||
if (key !== null && decisionKeys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
if (ranges.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const parsed = tryParseSingleIp(ip);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
return ranges.some(
|
||||
(range) => range.family === parsed.family && parsed.value >= range.start && parsed.value <= range.end,
|
||||
);
|
||||
}
|
||||
|
||||
export function resetIpBanExemptionsForTesting(): void {
|
||||
exemptDecisionKeys = null;
|
||||
exemptions = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {isIpBanExempt, resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('isIpBanExempt', () => {
|
||||
let originalExemptIps: Array<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
const config = getConfig();
|
||||
originalExemptIps = config.ipBanExemptIps;
|
||||
config.ipBanExemptIps = ['198.51.100.7', '2001:db8:6::', '2001:db8:1200:1000::/56', '203.0.113.0/24'];
|
||||
resetIpBanExemptionsForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getConfig().ipBanExemptIps = originalExemptIps;
|
||||
resetIpBanExemptionsForTesting();
|
||||
});
|
||||
|
||||
it('matches a bare IPv4 address exactly', () => {
|
||||
expect(isIpBanExempt('198.51.100.7')).toBe(true);
|
||||
expect(isIpBanExempt('198.51.100.8')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches a bare IPv6 address on its /64', () => {
|
||||
expect(isIpBanExempt('2001:db8:6::abcd')).toBe(true);
|
||||
expect(isIpBanExempt('2001:db8:7::1')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches every address inside a CIDR range', () => {
|
||||
expect(isIpBanExempt('2001:db8:1200:1000::1')).toBe(true);
|
||||
expect(isIpBanExempt('2001:db8:1200:10ff:ffff:ffff:ffff:ffff')).toBe(true);
|
||||
expect(isIpBanExempt('2001:db8:1200:1100::1')).toBe(false);
|
||||
expect(isIpBanExempt('2001:db8:1200:fff::1')).toBe(false);
|
||||
expect(isIpBanExempt('203.0.113.200')).toBe(true);
|
||||
expect(isIpBanExempt('::ffff:203.0.113.200')).toBe(true);
|
||||
expect(isIpBanExempt('203.0.114.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match empty or unparsable input', () => {
|
||||
expect(isIpBanExempt(null)).toBe(false);
|
||||
expect(isIpBanExempt('')).toBe(false);
|
||||
expect(isIpBanExempt('not-an-ip')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import {createHash} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
|
||||
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {
|
||||
createChannelID,
|
||||
@@ -75,6 +76,7 @@ import {
|
||||
mapUserGuildSettingsToResponse,
|
||||
mapUserSettingsToResponse,
|
||||
mapUserToPrivateResponse,
|
||||
mapWebAuthnCredentialToResponse,
|
||||
} from '@app/api/user/UserMappers';
|
||||
import {isUserAdult} from '@app/api/utils/AgeUtils';
|
||||
import {deriveDominantAvatarColor} from '@app/api/utils/AvatarColorUtils';
|
||||
@@ -97,7 +99,6 @@ import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import {pushServiceDeliveryEnrols} from '@fluxer/schema/src/domains/admin/PushServiceDeliverySchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {VoiceStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
@@ -431,13 +432,6 @@ export class RpcService {
|
||||
}),
|
||||
};
|
||||
case 'send_apns_push': {
|
||||
const deliveryConfig = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
|
||||
if (pushServiceDeliveryEnrols(deliveryConfig, request.user_id.toString())) {
|
||||
Logger.warn(
|
||||
{userId: request.user_id.toString(), configVersion: deliveryConfig.config_version},
|
||||
'push service delivery path mismatch',
|
||||
);
|
||||
}
|
||||
const result = await sendApnsPush({
|
||||
userId: request.user_id.toString(),
|
||||
subscriptionId: request.subscription_id,
|
||||
@@ -644,7 +638,7 @@ export class RpcService {
|
||||
};
|
||||
}
|
||||
case 'get_push_service_delivery_config': {
|
||||
const config = await this.instanceConfigRepository.getPushServiceDeliveryConfig();
|
||||
const config = await this.instanceConfigRepository.getLegacyPushServiceDeliveryWire();
|
||||
return {
|
||||
type: 'get_push_service_delivery_config',
|
||||
data: {config},
|
||||
@@ -1199,12 +1193,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,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {addGiftCodeDuration} from '@app/api/models/GiftCode';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
|
||||
import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
@@ -62,6 +62,7 @@ export class StripePremiumService {
|
||||
premium_will_cancel: false,
|
||||
premium_billing_cycle: billingCycle,
|
||||
premium_grace_ends_at: null,
|
||||
premium_flags: clearPerksSanitizedFlag(user.premiumFlags),
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
@@ -90,6 +91,7 @@ export class StripePremiumService {
|
||||
premium_since: this.resolvePremiumSince(user.premiumSince, premiumSinceAnchor, now),
|
||||
premium_until: null,
|
||||
premium_lifetime_sequence: visionarySequence,
|
||||
premium_flags: clearPerksSanitizedFlag(user.premiumFlags),
|
||||
has_ever_purchased: hasEverPurchased,
|
||||
premium_will_cancel: false,
|
||||
premium_billing_cycle: null,
|
||||
@@ -127,6 +129,7 @@ export class StripePremiumService {
|
||||
};
|
||||
if ((user.premiumType ?? 0) <= 0) {
|
||||
patch.premium_type = premiumType;
|
||||
patch.premium_flags = clearPerksSanitizedFlag(user.premiumFlags);
|
||||
patch.premium_since = this.resolvePremiumSince(user.premiumSince, null, now);
|
||||
}
|
||||
if (hasEverPurchased && !user.hasEverPurchased) {
|
||||
|
||||
@@ -261,7 +261,7 @@ export class StripeRefundService {
|
||||
const subscriptionId = refund.metadata.subscription_id;
|
||||
if (subscriptionId) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund', subscriptionId);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, userId: user.id.toString(), subscriptionId},
|
||||
|
||||
@@ -174,7 +174,7 @@ export class StripeSubscriptionService {
|
||||
}
|
||||
}
|
||||
|
||||
async cancelSubscriptionImmediately(userId: UserID, reason?: string): Promise<void> {
|
||||
async cancelSubscriptionImmediately(userId: UserID, reason?: string, expectedSubscriptionId?: string): Promise<void> {
|
||||
if (!this.stripe) {
|
||||
throw new StripePaymentNotAvailableError();
|
||||
}
|
||||
@@ -185,6 +185,18 @@ export class StripeSubscriptionService {
|
||||
if (!user.stripeSubscriptionId) {
|
||||
throw new StripeNoActiveSubscriptionError();
|
||||
}
|
||||
if (expectedSubscriptionId && user.stripeSubscriptionId !== expectedSubscriptionId) {
|
||||
Logger.info(
|
||||
{
|
||||
userId: user.id.toString(),
|
||||
expectedSubscriptionId,
|
||||
currentSubscriptionId: user.stripeSubscriptionId,
|
||||
reason: reason ?? null,
|
||||
},
|
||||
'Skipping immediate cancellation because the target subscription is no longer the current one',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const canceledSubscription = await this.stripe.subscriptions.cancel(
|
||||
user.stripeSubscriptionId,
|
||||
|
||||
@@ -487,4 +487,90 @@ describe('StripeRefundService self-serve refund', () => {
|
||||
expect(idempotencyKeys[1]).toContain('retry-1');
|
||||
});
|
||||
});
|
||||
describe('self-serve refund teardown targeting', () => {
|
||||
function trackingSubscriptionDeleteHandler(deleted: Array<string>) {
|
||||
return http.delete(`${STRIPE_API_BASE}/v1/subscriptions/:id`, ({params}) => {
|
||||
deleted.push(String(params.id));
|
||||
return HttpResponse.json({id: params.id, object: 'subscription', status: 'canceled'});
|
||||
});
|
||||
}
|
||||
|
||||
function buildRefundUpdatedEvent(opts: {
|
||||
eventId: string;
|
||||
refundId: string;
|
||||
userId: string;
|
||||
invoiceId: string;
|
||||
subscriptionId: string;
|
||||
}): StripeWebhookEventData {
|
||||
return {
|
||||
id: opts.eventId,
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: opts.refundId,
|
||||
object: 'refund',
|
||||
status: 'succeeded',
|
||||
amount: 2500,
|
||||
currency: 'usd',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: opts.userId,
|
||||
invoice_id: opts.invoiceId,
|
||||
subscription_id: opts.subscriptionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('leaves a newer subscription alone when the refunded one is no longer current', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
const deleted: Array<string> = [];
|
||||
server.use(trackingSubscriptionDeleteHandler(deleted));
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: 'sub_bought_after_the_refund',
|
||||
});
|
||||
await sendWebhook(
|
||||
buildRefundUpdatedEvent({
|
||||
eventId: 'evt_stale_teardown',
|
||||
refundId: 're_stale_teardown',
|
||||
userId: account.userId,
|
||||
invoiceId: 'in_stale_teardown',
|
||||
subscriptionId: 'sub_refunded_and_already_gone',
|
||||
}),
|
||||
);
|
||||
expect(deleted).toEqual([]);
|
||||
const userRepository = new UserRepository();
|
||||
const user = await userRepository.findUnique(userId);
|
||||
expect(user!.stripeSubscriptionId).toBe('sub_bought_after_the_refund');
|
||||
});
|
||||
|
||||
test('cancels the subscription when the refunded one is still current', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
const deleted: Array<string> = [];
|
||||
server.use(trackingSubscriptionDeleteHandler(deleted));
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
|
||||
});
|
||||
await sendWebhook(
|
||||
buildRefundUpdatedEvent({
|
||||
eventId: 'evt_current_teardown',
|
||||
refundId: 're_current_teardown',
|
||||
userId: account.userId,
|
||||
invoiceId: 'in_current_teardown',
|
||||
subscriptionId: MOCK_SUBSCRIPTION_ID,
|
||||
}),
|
||||
);
|
||||
expect(deleted).toEqual([MOCK_SUBSCRIPTION_ID]);
|
||||
const userRepository = new UserRepository();
|
||||
const user = await userRepository.findUnique(userId);
|
||||
expect(user!.stripeSubscriptionId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -795,12 +795,6 @@ export class NoopGatewayService extends IGatewayService {
|
||||
|
||||
async dispatchPresence(_params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void> {}
|
||||
|
||||
async invalidatePushBadgeCount(_params: {userId: UserID}): Promise<void> {}
|
||||
|
||||
async invalidatePushBadgeCounts(_params: {userIds: Array<UserID>}): Promise<void> {}
|
||||
|
||||
async invalidatePushSubscriptions(_params: {userId: UserID}): Promise<void> {}
|
||||
|
||||
async clearPushChannelNotifications(_params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
import {getCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {accountPolicyContactHasCapability} from '@app/api/risk/AccountPolicyService';
|
||||
import {getCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache';
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
SuspiciousActivityFlags,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {resolveProfileTimezoneAssignment} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {RequiredAction} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
@@ -275,6 +278,10 @@ export function createPremiumClearPatch(): Partial<UserRow> {
|
||||
return mapExpiredPremiumFields(() => null) as Partial<UserRow>;
|
||||
}
|
||||
|
||||
export function clearPerksSanitizedFlag(premiumFlags: number): number {
|
||||
return premiumFlags & ~PremiumFlags.PERKS_SANITIZED;
|
||||
}
|
||||
|
||||
const PROFILE_SUBSTRING_EXEMPT_FLAGS = UserFlags.STAFF;
|
||||
|
||||
export function isProfileSubstringExempt(user: Pick<PremiumCheckable, 'flags'>): boolean {
|
||||
@@ -285,6 +292,8 @@ export function isBugHunterBotUser(user: Pick<User, 'flags' | 'isBot'>): boolean
|
||||
return user.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
|
||||
}
|
||||
|
||||
export function canUseProfileTimezone(user: Pick<PremiumCheckable, 'flags'>): boolean {
|
||||
return (user.flags & UserFlags.STAFF) !== 0n;
|
||||
export async function canUseProfileTimezone(user: User): Promise<boolean> {
|
||||
const config = await getInstanceConfigRepository().getProfileTimezoneConfig();
|
||||
const targeting = await resolveExperimentTargeting(user, [config]);
|
||||
return resolveProfileTimezoneAssignment(config, user.id.toString(), targeting).enabled;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ 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 {canUseProfileTimezone, getRequiredActions} from '@app/api/user/UserHelpers';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {getRequiredActions} from '@app/api/user/UserHelpers';
|
||||
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
|
||||
import type {ChannelMessageNotifications} from '@fluxer/constants/src/NotificationConstants';
|
||||
import {
|
||||
@@ -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,
|
||||
@@ -119,7 +121,6 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
|
||||
const isStaff = (user.flags & UserFlags.STAFF) !== 0n;
|
||||
const partialResponse = mapUserToPartialResponse(user);
|
||||
const isActuallyPremium = user.isPremium();
|
||||
const includeProfileTimezone = canUseProfileTimezone(user);
|
||||
const traitSet = new Set<string>();
|
||||
for (const trait of user.traits ?? []) {
|
||||
if (trait && trait !== 'premium') {
|
||||
@@ -145,12 +146,8 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
|
||||
bio: user.bio,
|
||||
pronouns: user.pronouns,
|
||||
accent_color: user.accentColor,
|
||||
...(includeProfileTimezone
|
||||
? {
|
||||
timezone: user.timezone,
|
||||
timezone_privacy_flags: user.timezonePrivacyFlags,
|
||||
}
|
||||
: {}),
|
||||
timezone: user.timezone,
|
||||
timezone_privacy_flags: user.timezonePrivacyFlags,
|
||||
banner: stripBannerForUser(user),
|
||||
banner_color: user.bannerColor,
|
||||
mfa_enabled: authenticatorTypes.length > 0,
|
||||
@@ -420,3 +417,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({
|
||||
|
||||
@@ -127,10 +127,10 @@ export class UserAccountLookupService {
|
||||
: await this.getProfileFieldPrivacyContext(userId, targetId);
|
||||
const timezoneVisible =
|
||||
!restrictProfile &&
|
||||
canUseProfileTimezone(user) &&
|
||||
user.timezone != null &&
|
||||
profileFieldPrivacyContext != null &&
|
||||
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext);
|
||||
this.canViewProfileField(user.timezonePrivacyFlags, profileFieldPrivacyContext) &&
|
||||
(await canUseProfileTimezone(user));
|
||||
const [mutualFriends, mutualGuilds, connections] = await Promise.all([
|
||||
withMutualFriends && userId !== targetId ? this.getMutualFriends(userId, targetId) : undefined,
|
||||
withMutualGuilds && userId !== targetId ? this.getMutualGuilds(userId, targetId) : undefined,
|
||||
|
||||
@@ -83,7 +83,8 @@ export class UserAccountProfileService {
|
||||
if (data.accent_color !== undefined) {
|
||||
await this.processAccentColorUpdate({user, accentColor: data.accent_color, updates});
|
||||
}
|
||||
const canUpdateProfileTimezone = canUseProfileTimezone(user);
|
||||
const canUpdateProfileTimezone =
|
||||
(data.timezone !== undefined || data.timezone_privacy_flags !== undefined) && (await canUseProfileTimezone(user));
|
||||
if (canUpdateProfileTimezone && data.timezone !== undefined) {
|
||||
const nextTimezone = this.processTimezoneUpdate({user, timezone: data.timezone, updates});
|
||||
if (nextTimezone !== null && user.timezone === null && data.timezone_privacy_flags === undefined) {
|
||||
|
||||
@@ -85,11 +85,14 @@ function hasProfileCustomizationUpdate(data: UserUpdatePayload): boolean {
|
||||
return EMAIL_VERIFICATION_REQUIRED_PROFILE_UPDATE_FIELDS.some((field) => data[field] !== undefined);
|
||||
}
|
||||
|
||||
function stripUnauthorizedProfileTimezoneUpdate(
|
||||
async function stripUnauthorizedProfileTimezoneUpdate(
|
||||
user: User,
|
||||
body: UserUpdateWithVerificationRequest,
|
||||
): UserUpdateWithVerificationRequest {
|
||||
if (canUseProfileTimezone(user)) {
|
||||
): Promise<UserUpdateWithVerificationRequest> {
|
||||
if (body.timezone === undefined && body.timezone_privacy_flags === undefined) {
|
||||
return body;
|
||||
}
|
||||
if (await canUseProfileTimezone(user)) {
|
||||
return body;
|
||||
}
|
||||
const {timezone: _timezone, timezone_privacy_flags: _timezonePrivacyFlags, ...rest} = body;
|
||||
@@ -187,7 +190,7 @@ export class UserAccountRequestService {
|
||||
const {ctx, body, authSession} = params;
|
||||
let {user} = params;
|
||||
const oldEmail = user.email;
|
||||
const sanitizedBody = stripUnauthorizedProfileTimezoneUpdate(user, body);
|
||||
const sanitizedBody = await stripUnauthorizedProfileTimezoneUpdate(user, body);
|
||||
const {
|
||||
mfa_method: _mfaMethod,
|
||||
mfa_code: _mfaCode,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -392,7 +392,6 @@ export class UserContentService {
|
||||
provider_environment: null,
|
||||
};
|
||||
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
|
||||
await this.gatewayService.invalidatePushSubscriptions({userId});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
@@ -468,7 +467,6 @@ export class UserContentService {
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
await this.userRepository.deletePushSubscription(userId, subscriptionId);
|
||||
await this.gatewayService.invalidatePushSubscriptions({userId});
|
||||
}
|
||||
|
||||
async rotatePushSubscription(params: {
|
||||
@@ -504,7 +502,6 @@ export class UserContentService {
|
||||
provider_environment: null,
|
||||
};
|
||||
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
|
||||
await this.gatewayService.invalidatePushSubscriptions({userId});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
@@ -530,7 +527,6 @@ export class UserContentService {
|
||||
provider_environment: providerEnvironment,
|
||||
};
|
||||
const subscription = await this.userRepository.createPushSubscription(data);
|
||||
await this.gatewayService.invalidatePushSubscriptions({userId});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createFriendship} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
|
||||
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} from '@app/api/test/TestRequestBuilder';
|
||||
import {
|
||||
ProfileFieldPrivacyFlags,
|
||||
type ProfilePrivacyLevel,
|
||||
ProfilePrivacyLevels,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
|
||||
import {DEFAULT_PROFILE_TIMEZONE_CONFIG} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
|
||||
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -31,12 +31,12 @@ async function updateProfileTimezone(
|
||||
return createBuilder<UserPrivateResponse>(harness, token).patch('/users/@me').body(data).execute();
|
||||
}
|
||||
|
||||
async function setUserFlags(harness: ApiTestHarness, userId: string, flags: bigint): Promise<void> {
|
||||
await createBuilder(harness, '')
|
||||
.patch(`/test/users/${userId}/flags`)
|
||||
.body({flags: flags.toString()})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
async function setProfileTimezoneUsers(userIds: Array<string>): Promise<void> {
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
included_user_ids: userIds,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateProfilePrivacy(
|
||||
@@ -76,7 +76,7 @@ describe('User Profile Timezone Visibility', () => {
|
||||
it('defaults timezone visibility to everyone when a timezone is set', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const viewerAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
expect(updated.timezone).toBe(TEST_TIMEZONE);
|
||||
expect(updated.timezone_privacy_flags).toBe(ProfileFieldPrivacyFlags.EVERYONE);
|
||||
@@ -86,7 +86,7 @@ describe('User Profile Timezone Visibility', () => {
|
||||
});
|
||||
it('restores default timezone visibility when a timezone is set again without explicit flags', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: 0,
|
||||
@@ -97,7 +97,7 @@ describe('User Profile Timezone Visibility', () => {
|
||||
});
|
||||
it('hides timezone from the public profile when privacy flags are unset', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: 0,
|
||||
@@ -109,7 +109,7 @@ describe('User Profile Timezone Visibility', () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const friendAccount = await createTestAccount(harness);
|
||||
const guildMemberAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
|
||||
@@ -125,7 +125,7 @@ describe('User Profile Timezone Visibility', () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const friendAccount = await createTestAccount(harness);
|
||||
const guildMemberAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: ProfileFieldPrivacyFlags.MUTUAL_GUILDS,
|
||||
@@ -140,7 +140,7 @@ describe('User Profile Timezone Visibility', () => {
|
||||
it('hides timezone when full profile privacy restricts the viewer', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const guildMemberAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
await updateProfilePrivacy(harness, targetAccount.token, ProfilePrivacyLevels.FRIENDS_ONLY);
|
||||
await createSharedGuild(harness, targetAccount.token, guildMemberAccount.token);
|
||||
@@ -148,23 +148,47 @@ describe('User Profile Timezone Visibility', () => {
|
||||
expect(profile.profile_limited).toBe(true);
|
||||
expect(profile.timezone_offset).toBeNull();
|
||||
});
|
||||
it('ignores profile timezone updates from non-staff users', async () => {
|
||||
it('ignores profile timezone updates from users outside the experiment', async () => {
|
||||
const targetAccount = await createTestAccount(harness, {skipEmailVerification: true});
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
expect(updated).not.toHaveProperty('timezone');
|
||||
expect(updated).not.toHaveProperty('timezone_privacy_flags');
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {
|
||||
timezone: TEST_TIMEZONE,
|
||||
timezone_privacy_flags: ProfileFieldPrivacyFlags.FRIENDS,
|
||||
});
|
||||
expect(updated.timezone).toBeNull();
|
||||
expect(updated.timezone_privacy_flags).toBe(ProfileFieldPrivacyFlags.EVERYONE);
|
||||
});
|
||||
it('hides stored profile timezone after the user no longer has the staff flag', async () => {
|
||||
it('ignores profile timezone updates from users excluded from a full rollout', async () => {
|
||||
const targetAccount = await createTestAccount(harness, {skipEmailVerification: true});
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
excluded_user_ids: [targetAccount.userId],
|
||||
});
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
expect(updated.timezone).toBeNull();
|
||||
});
|
||||
it('lets members of an included guild set and show a timezone', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const viewerAccount = await createTestAccount(harness);
|
||||
await setUserFlags(harness, targetAccount.userId, UserFlags.STAFF);
|
||||
const guild = await createGuild(harness, targetAccount.token, 'Timezone Rollout Guild');
|
||||
await getInstanceConfigRepository().setProfileTimezoneConfig({
|
||||
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
expect(updated.timezone).toBe(TEST_TIMEZONE);
|
||||
await createFriendship(harness, targetAccount, viewerAccount);
|
||||
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
|
||||
expect(profile.timezone_offset).toBe(TEST_TIMEZONE_OFFSET);
|
||||
});
|
||||
it('hides stored profile timezone after the user leaves the experiment', async () => {
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
const viewerAccount = await createTestAccount(harness);
|
||||
await setProfileTimezoneUsers([targetAccount.userId]);
|
||||
await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
|
||||
await setUserFlags(harness, targetAccount.userId, 0n);
|
||||
const currentUser = await createBuilder<UserPrivateResponse>(harness, targetAccount.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(currentUser).not.toHaveProperty('timezone');
|
||||
expect(currentUser).not.toHaveProperty('timezone_privacy_flags');
|
||||
await setProfileTimezoneUsers([]);
|
||||
await createFriendship(harness, targetAccount, viewerAccount);
|
||||
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
|
||||
expect(profile.timezone_offset).toBeNull();
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getSubscriptionPremiumPeriodEnd,
|
||||
getSubscriptionStartDate,
|
||||
} from '@app/api/stripe/StripeSubscriptionPeriod';
|
||||
import {createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
|
||||
import {clearPerksSanitizedFlag, createPremiumClearPatch, getEffectivePremiumUntil} from '@app/api/user/UserHelpers';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
@@ -73,6 +73,10 @@ function buildStripePremiumRepairPatch(user: User, subscription: Stripe.Subscrip
|
||||
if (user.stripeSubscriptionId !== subscription.id) {
|
||||
patch.stripe_subscription_id = subscription.id;
|
||||
}
|
||||
const clearedPremiumFlags = clearPerksSanitizedFlag(user.premiumFlags);
|
||||
if (user.premiumFlags !== clearedPremiumFlags) {
|
||||
patch.premium_flags = clearedPremiumFlags;
|
||||
}
|
||||
if (subscriptionCustomerId && user.stripeCustomerId !== subscriptionCustomerId) {
|
||||
patch.stripe_customer_id = subscriptionCustomerId;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import processPremiumStateReconciliationQueue from '@app/api/worker/tasks/ProcessPremiumStateReconciliationQueue';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {PremiumFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import type Stripe from 'stripe';
|
||||
import {afterEach, describe, expect, test} from 'vitest';
|
||||
@@ -48,6 +49,28 @@ function createCancelledSubscription(endedAtMs: number): Stripe.Subscription {
|
||||
} as unknown as Stripe.Subscription;
|
||||
}
|
||||
|
||||
function createActiveSubscription(periodEndMs: number): Stripe.Subscription {
|
||||
return {
|
||||
id: 'sub_test',
|
||||
status: 'active',
|
||||
customer: 'cus_test',
|
||||
ended_at: null,
|
||||
canceled_at: null,
|
||||
cancel_at: null,
|
||||
cancel_at_period_end: false,
|
||||
trial_end: null,
|
||||
start_date: Math.floor((Date.now() - 200 * ONE_DAY_MS) / 1000),
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
current_period_end: Math.floor(periodEndMs / 1000),
|
||||
price: {recurring: {interval: 'month'}},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Stripe.Subscription;
|
||||
}
|
||||
|
||||
function createPaidInvoice(periodEndMs: number): Stripe.Invoice {
|
||||
return {
|
||||
id: 'in_test',
|
||||
@@ -299,4 +322,27 @@ describe('processPremiumStateReconciliationQueue', () => {
|
||||
expect(patches[0].premium_until).toBeNull();
|
||||
expect(patches[0].premium_since).toBeNull();
|
||||
});
|
||||
test('clears the perks-sanitized latch once the subscription is active again', async () => {
|
||||
const queueService = createQueueService();
|
||||
await queueService.enqueueUser(USER_ID, new Date(Date.now() - 1000));
|
||||
|
||||
const periodEndMs = Math.floor((Date.now() + 20 * ONE_DAY_MS) / 1000) * 1000;
|
||||
const user = createPremiumUser({
|
||||
premium_until: new Date(periodEndMs),
|
||||
premium_flags: PremiumFlags.PERKS_SANITIZED,
|
||||
});
|
||||
const {userRepository, patches, extras} = createCapturingDeps(user);
|
||||
|
||||
setWorkerDependenciesForTest({
|
||||
premiumStateReconciliationQueueService: queueService,
|
||||
stripe: createStripeStub(createActiveSubscription(periodEndMs), []),
|
||||
userRepository,
|
||||
...extras,
|
||||
});
|
||||
|
||||
await processPremiumStateReconciliationQueue({}, createHelpers());
|
||||
|
||||
expect(patches).toHaveLength(1);
|
||||
expect(patches[0].premium_flags).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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_relay.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_relay:
|
||||
config.push_relay.relay_consent_accepted === pushRelayConsentAccepted
|
||||
? undefined
|
||||
: {relay_consent_accepted: pushRelayConsentAccepted},
|
||||
registration: {mode: registrationMode},
|
||||
app_public: {
|
||||
branding: {
|
||||
@@ -804,6 +813,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
singleCommunityEnabled,
|
||||
singleCommunityNameTrimmed,
|
||||
directMessagesDisabled,
|
||||
pushRelayConsentAccepted,
|
||||
premiumMode,
|
||||
serviceAvailability,
|
||||
serviceSelection,
|
||||
@@ -972,6 +982,14 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.integration-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'push_relay_consent' && (
|
||||
<PushRelayConsentStep
|
||||
accepted={pushRelayConsentAccepted}
|
||||
disabled={submitting}
|
||||
onChange={setPushRelayConsentAccepted}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.push-relay-consent-step"
|
||||
/>
|
||||
)}
|
||||
{step === 'services' && (
|
||||
<ServicesStep
|
||||
available={serviceAvailability}
|
||||
@@ -996,6 +1014,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
|
||||
singleCommunityEnabled={singleCommunityEnabled}
|
||||
directMessagesDisabled={directMessagesDisabled}
|
||||
attachmentExpiryEnabled={mediaExpiryDraft.enabled}
|
||||
pushRelayConsentAccepted={pushRelayConsentAccepted}
|
||||
premiumMode={premiumMode}
|
||||
submitError={submitError}
|
||||
data-flx="app.setup.self-hosted-setup-wizard-gate.finish-step"
|
||||
|
||||
@@ -17,6 +17,7 @@ export type WizardStep =
|
||||
| 'integration_captcha'
|
||||
| 'integration_email'
|
||||
| 'integration_bluesky'
|
||||
| 'push_relay_consent'
|
||||
| 'services'
|
||||
| 'premium'
|
||||
| 'finish';
|
||||
@@ -36,6 +37,7 @@ export const CONFIGURE_STEPS: ReadonlyArray<WizardStep> = [
|
||||
'integration_captcha',
|
||||
'integration_email',
|
||||
'integration_bluesky',
|
||||
'push_relay_consent',
|
||||
'services',
|
||||
'premium',
|
||||
'finish',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerFiel
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {Switch} from '@app/features/ui/components/form/FormSwitch';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {RadioGroup, type RadioOption} from '@app/features/ui/radio_group/RadioGroup';
|
||||
import {ThemeSelector} from '@app/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent';
|
||||
import {LanguageSelector} from '@app/features/user/components/modals/tabs/LanguageTab';
|
||||
@@ -27,6 +28,8 @@ import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
const PUSH_RELAY_NOTICE_URL = 'https://fluxer.com/push-relay';
|
||||
|
||||
export type RegistrationMode = 'open' | 'approval' | 'closed';
|
||||
export type PremiumMode = 'mirror' | 'everyone';
|
||||
|
||||
@@ -263,6 +266,29 @@ const MEDIA_RENEW_WINDOW_LABEL_DESCRIPTOR = msg({
|
||||
comment: 'Label for attachment decay renewal window.',
|
||||
});
|
||||
|
||||
const PUSH_RELAY_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Mobile push notifications',
|
||||
comment: 'Setup wizard push relay consent step title.',
|
||||
});
|
||||
const PUSH_RELAY_BODY_DESCRIPTOR = msg({
|
||||
message:
|
||||
"The official Fluxer mobile apps receive notifications through Fluxer's push relay, which hands them to Apple and Google. Self-hosted UnifiedPush and ntfy endpoints never reach the relay and need no agreement.",
|
||||
comment: 'Setup wizard push relay consent step body.',
|
||||
});
|
||||
const PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR = msg({
|
||||
message: 'Accept the push relay supplemental privacy notice',
|
||||
comment: 'Label for the push relay consent switch during setup.',
|
||||
});
|
||||
const PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Leaving this off keeps the relay unused and drops notifications to the official mobile apps. You can accept it later in the admin panel.',
|
||||
comment: 'Description for the push relay consent switch during setup.',
|
||||
});
|
||||
const PUSH_RELAY_NOTICE_LINK_DESCRIPTOR = msg({
|
||||
message: 'Read the supplemental privacy notice',
|
||||
comment: 'Link to the push relay supplemental privacy notice shown during setup.',
|
||||
});
|
||||
|
||||
const SERVICES_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Optional services',
|
||||
comment: 'Setup wizard optional services step title.',
|
||||
@@ -353,10 +379,22 @@ const SUMMARY_ATTACHMENT_EXPIRY_DESCRIPTOR = msg({
|
||||
message: 'Attachment expiration',
|
||||
comment: 'Summary row label for the attachment expiry choice in the setup wizard.',
|
||||
});
|
||||
const SUMMARY_PUSH_RELAY_DESCRIPTOR = msg({
|
||||
message: 'Push relay notice',
|
||||
comment: 'Summary row label for the push relay consent on the setup wizard finish step.',
|
||||
});
|
||||
const SUMMARY_PREMIUM_DESCRIPTOR = msg({
|
||||
message: 'Premium model',
|
||||
comment: 'Summary row label for the premium model in the setup wizard.',
|
||||
});
|
||||
const SUMMARY_ACCEPTED_DESCRIPTOR = msg({
|
||||
message: 'Accepted',
|
||||
comment: 'Summary value when the operator accepted the push relay notice.',
|
||||
});
|
||||
const SUMMARY_NOT_ACCEPTED_DESCRIPTOR = msg({
|
||||
message: 'Not accepted',
|
||||
comment: 'Summary value when the operator left the push relay notice unaccepted.',
|
||||
});
|
||||
const SUMMARY_ON_DESCRIPTOR = msg({
|
||||
message: 'Enabled',
|
||||
comment: 'Summary value when a setup option is enabled.',
|
||||
@@ -1531,6 +1569,40 @@ export const IntegrationStep = observer(
|
||||
},
|
||||
);
|
||||
|
||||
export const PushRelayConsentStep = observer(
|
||||
({accepted, disabled, onChange}: {accepted: boolean; disabled: boolean; onChange: (value: boolean) => void}) => {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-step">
|
||||
<StepHeader
|
||||
title={i18n._(PUSH_RELAY_TITLE_DESCRIPTOR)}
|
||||
body={i18n._(PUSH_RELAY_BODY_DESCRIPTOR)}
|
||||
data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.step-header"
|
||||
/>
|
||||
<Switch
|
||||
label={i18n._(PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR)}
|
||||
description={i18n._(PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR)}
|
||||
value={accepted}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-switch"
|
||||
/>
|
||||
<FocusRing data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.focus-ring">
|
||||
<a
|
||||
className={styles.noticeLink}
|
||||
href={PUSH_RELAY_NOTICE_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-flx="app.self-hosted-setup-wizard-gate.push-relay-notice-link"
|
||||
>
|
||||
{i18n._(PUSH_RELAY_NOTICE_LINK_DESCRIPTOR)}
|
||||
</a>
|
||||
</FocusRing>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ServicesStep = observer(
|
||||
({
|
||||
available,
|
||||
@@ -1650,6 +1722,7 @@ export const FinishStep = observer(
|
||||
singleCommunityEnabled,
|
||||
directMessagesDisabled,
|
||||
attachmentExpiryEnabled,
|
||||
pushRelayConsentAccepted,
|
||||
premiumMode,
|
||||
submitError,
|
||||
}: {
|
||||
@@ -1658,6 +1731,7 @@ export const FinishStep = observer(
|
||||
singleCommunityEnabled: boolean;
|
||||
directMessagesDisabled: boolean;
|
||||
attachmentExpiryEnabled: boolean;
|
||||
pushRelayConsentAccepted: boolean;
|
||||
premiumMode: PremiumMode;
|
||||
submitError: string | null;
|
||||
}) => {
|
||||
@@ -1705,10 +1779,17 @@ export const FinishStep = observer(
|
||||
value={attachmentExpiryEnabled ? onLabel : offLabel}
|
||||
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--5"
|
||||
/>
|
||||
<SummaryRow
|
||||
label={i18n._(SUMMARY_PUSH_RELAY_DESCRIPTOR)}
|
||||
value={
|
||||
pushRelayConsentAccepted ? i18n._(SUMMARY_ACCEPTED_DESCRIPTOR) : i18n._(SUMMARY_NOT_ACCEPTED_DESCRIPTOR)
|
||||
}
|
||||
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6"
|
||||
/>
|
||||
<SummaryRow
|
||||
label={i18n._(SUMMARY_PREMIUM_DESCRIPTOR)}
|
||||
value={premiumLabel}
|
||||
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6"
|
||||
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--7"
|
||||
/>
|
||||
</div>
|
||||
{submitError && (
|
||||
|
||||
@@ -21,6 +21,11 @@ export const Endpoints = {
|
||||
AUTH_HANDOFF_INFO: (code: string) => `/auth/handoff/${code}/info`,
|
||||
AUTH_HANDOFF_STATUS: (code: string) => `/auth/handoff/${code}/status`,
|
||||
AUTH_HANDOFF_CANCEL: (code: string) => `/auth/handoff/${code}`,
|
||||
AUTH_PASSKEY_BRIDGE: '/auth/passkey-bridge',
|
||||
AUTH_PASSKEY_BRIDGE_OPTIONS: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/options`,
|
||||
AUTH_PASSKEY_BRIDGE_COMPLETE: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/complete`,
|
||||
AUTH_PASSKEY_BRIDGE_CANCEL: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/cancel`,
|
||||
AUTH_PASSKEY_BRIDGE_REDEEM: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/redeem`,
|
||||
AUTH_FORGOT_PASSWORD: '/auth/forgot',
|
||||
AUTH_RESET_PASSWORD: '/auth/reset',
|
||||
AUTH_VALIDATE_RESET_PASSWORD_TOKEN: (token: string) => `/auth/reset/${encodeURIComponent(token)}`,
|
||||
@@ -173,6 +178,10 @@ export const Endpoints = {
|
||||
USER_MFA_WEBAUTHN_REGISTRATION_OPTIONS: '/users/@me/mfa/webauthn/credentials/registration-options',
|
||||
USER_MFA_WEBAUTHN_CREDENTIAL: (credentialId: string) => `/users/@me/mfa/webauthn/credentials/${credentialId}`,
|
||||
USER_MFA_WEBAUTHN_TWO_FACTOR: '/users/@me/mfa/webauthn/two-factor',
|
||||
USER_MFA_WEBAUTHN_MIGRATION: '/users/@me/mfa/webauthn/migration',
|
||||
USER_MFA_WEBAUTHN_MIGRATION_REGISTRATION_OPTIONS: '/users/@me/mfa/webauthn/migration/registration-options',
|
||||
USER_PASSKEY_BRIDGE: '/users/@me/passkey-bridge',
|
||||
USER_PASSKEY_BRIDGE_REDEEM: (ceremonyId: string) => `/users/@me/passkey-bridge/${ceremonyId}/redeem`,
|
||||
USER_PHONE_SEND_VERIFICATION: '/users/@me/phone/send-verification',
|
||||
USER_PHONE_INBOUND_CHALLENGE: '/users/@me/phone/inbound-challenge',
|
||||
USER_PHONE_VERIFY: '/users/@me/phone/verify',
|
||||
|
||||
@@ -60,7 +60,7 @@ function environment(
|
||||
installKind: core.DomainMigrationInstallKind,
|
||||
overrides: Partial<core.DomainMigrationEnvironment> = {},
|
||||
): core.DomainMigrationEnvironment {
|
||||
return {installKind, electron: false, electronMigrationVersion: null, ...overrides};
|
||||
return {installKind, electron: false, electronMigrationVersion: null, electronPasskeyRpIds: [], ...overrides};
|
||||
}
|
||||
|
||||
function gateInput(overrides: Partial<core.DomainMigrationGateInput> = {}): core.DomainMigrationGateInput {
|
||||
@@ -70,7 +70,6 @@ function gateInput(overrides: Partial<core.DomainMigrationGateInput> = {}): core
|
||||
discovery: ENABLED_DISCOVERY,
|
||||
marker: null,
|
||||
now: NOW,
|
||||
relatedOriginsSupported: true,
|
||||
voiceActive: false,
|
||||
oneShotRoute: false,
|
||||
...overrides,
|
||||
@@ -255,7 +254,13 @@ describe('migration gate', () => {
|
||||
expect(core.shouldStartDomainMigration(gateInput())).toBe(true);
|
||||
expect(
|
||||
core.shouldStartDomainMigration(
|
||||
gateInput({environment: environment('none', {electron: true, electronMigrationVersion: 1})}),
|
||||
gateInput({
|
||||
environment: environment('none', {
|
||||
electron: true,
|
||||
electronMigrationVersion: 1,
|
||||
electronPasskeyRpIds: ['fluxer.app', 'fluxer.com'],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -272,7 +277,16 @@ describe('migration gate', () => {
|
||||
['Firefox web app', {environment: environment('firefox')}],
|
||||
['other web app', {environment: environment('other')}],
|
||||
['old desktop', {environment: environment('none', {electron: true})}],
|
||||
['no related origins', {relatedOriginsSupported: false}],
|
||||
[
|
||||
'desktop that cannot create fluxer.com passkeys',
|
||||
{
|
||||
environment: environment('none', {
|
||||
electron: true,
|
||||
electronMigrationVersion: 1,
|
||||
electronPasskeyRpIds: ['fluxer.app'],
|
||||
}),
|
||||
},
|
||||
],
|
||||
['in a voice call', {voiceActive: true}],
|
||||
['on a one-shot token route', {oneShotRoute: true}],
|
||||
])('blocks when %s', (_label, overrides) => {
|
||||
|
||||
@@ -27,10 +27,6 @@ const DISPLAY_MODES: ReadonlyArray<DomainMigrationDisplayMode> = [
|
||||
'minimal-ui',
|
||||
];
|
||||
|
||||
interface PublicKeyCredentialWithCapabilities {
|
||||
getClientCapabilities?: () => Promise<Record<string, boolean | undefined>>;
|
||||
}
|
||||
|
||||
export function readDomainMigrationDiscovery(): DomainMigrationDiscoveryResponse | null {
|
||||
return window.__FLUXER_BOOTSTRAP__?.instance.domain_migration ?? null;
|
||||
}
|
||||
@@ -68,20 +64,16 @@ export function readDomainMigrationEnvironment(): DomainMigrationEnvironment {
|
||||
installKind: detectDomainMigrationInstallKind(),
|
||||
electron: isElectronEnvironment(),
|
||||
electronMigrationVersion: window.electron?.domainMigration?.version ?? null,
|
||||
electronPasskeyRpIds: window.electron?.passkeyRpIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function browserSupportsRelatedOrigins(): Promise<boolean> {
|
||||
if (typeof PublicKeyCredential === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const credential = PublicKeyCredential as unknown as PublicKeyCredentialWithCapabilities;
|
||||
if (typeof credential.getClientCapabilities !== 'function') {
|
||||
return false;
|
||||
export async function desktopPasskeysSupported(): Promise<boolean> {
|
||||
if (!isElectronEnvironment()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const capabilities = await credential.getClientCapabilities();
|
||||
return capabilities.relatedOrigins === true;
|
||||
return (await window.electron?.passkeyIsSupported?.()) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfig';
|
||||
import type {StoredAccount} from '@app/features/auth/state/AccountStorage';
|
||||
import {isIOSMobileOrTabletUserAgent} from '@app/features/platform/notifications/NotificationAlertOptions';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import type {DomainMigrationDiscoveryResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
|
||||
@@ -256,6 +257,7 @@ export interface DomainMigrationEnvironment {
|
||||
installKind: DomainMigrationInstallKind;
|
||||
electron: boolean;
|
||||
electronMigrationVersion: number | null;
|
||||
electronPasskeyRpIds: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export function environmentAllowsDomainMigration(environment: DomainMigrationEnvironment): boolean {
|
||||
@@ -263,7 +265,11 @@ export function environmentAllowsDomainMigration(environment: DomainMigrationEnv
|
||||
return false;
|
||||
}
|
||||
if (environment.electron) {
|
||||
return environment.electronMigrationVersion !== null && environment.electronMigrationVersion >= 1;
|
||||
return (
|
||||
environment.electronMigrationVersion !== null &&
|
||||
environment.electronMigrationVersion >= 1 &&
|
||||
environment.electronPasskeyRpIds.includes(PASSKEY_MIGRATION_RP_ID)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -284,7 +290,6 @@ export interface DomainMigrationGateInput {
|
||||
discovery: DomainMigrationDiscoveryResponse | null;
|
||||
marker: DomainMigrationMarker | null;
|
||||
now: number;
|
||||
relatedOriginsSupported: boolean;
|
||||
voiceActive: boolean;
|
||||
oneShotRoute: boolean;
|
||||
}
|
||||
@@ -295,7 +300,6 @@ export function shouldStartDomainMigration(input: DomainMigrationGateInput): boo
|
||||
input.discovery?.enabled === true &&
|
||||
markerAllowsDomainMigration(input.marker, input.now) &&
|
||||
environmentAllowsDomainMigration(input.environment) &&
|
||||
input.relatedOriginsSupported &&
|
||||
!input.voiceActive &&
|
||||
!input.oneShotRoute
|
||||
);
|
||||
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
randomBase64Url,
|
||||
sha256Hex,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationCrypto';
|
||||
import {resolvePasskeyBridgeOpenerOrigin} from '@app/features/auth/utils/PasskeyBridgeProtocol';
|
||||
import type {SoundType} from '@app/features/notification/utils/SoundUtils';
|
||||
import {getProtectedLocalStorage, getProtectedSessionStorage} from '@app/features/platform/state/ProtectedWebStorage';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -593,7 +592,7 @@ export async function runDomainMigrationPreMount(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
const side = resolveDomainMigrationSide(window.location.origin);
|
||||
if (side === null || resolvePasskeyBridgeOpenerOrigin(window.location.origin, window.location.pathname) !== null) {
|
||||
if (side === null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {
|
||||
browserSupportsRelatedOrigins,
|
||||
desktopPasskeysSupported,
|
||||
readDomainMigrationDiscovery,
|
||||
readDomainMigrationEnvironment,
|
||||
} from '@app/features/app/domain_migration/DomainMigrationBrowser';
|
||||
@@ -50,28 +50,24 @@ function isOneShotRoute(pathname: string): boolean {
|
||||
return ONE_SHOT_ROUTE_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||||
}
|
||||
|
||||
function readGateInput(assignmentEnabled: boolean, relatedOriginsSupported: boolean): DomainMigrationGateInput {
|
||||
function readGateInput(assignmentEnabled: boolean): DomainMigrationGateInput {
|
||||
return {
|
||||
environment: readDomainMigrationEnvironment(),
|
||||
assignmentEnabled,
|
||||
discovery: readDomainMigrationDiscovery(),
|
||||
marker: readDomainMigrationMarker(getProtectedLocalStorage()),
|
||||
now: Date.now(),
|
||||
relatedOriginsSupported,
|
||||
voiceActive: isVoiceActive(),
|
||||
oneShotRoute: isOneShotRoute(window.location.pathname),
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluateSource(side: DomainMigrationSide, assignmentEnabled: boolean): Promise<void> {
|
||||
if (navigating || !shouldStartDomainMigration(readGateInput(assignmentEnabled, true))) {
|
||||
if (navigating || !shouldStartDomainMigration(readGateInput(assignmentEnabled))) {
|
||||
return;
|
||||
}
|
||||
const relatedOriginsSupported = await browserSupportsRelatedOrigins();
|
||||
if (
|
||||
navigating ||
|
||||
!shouldStartDomainMigration(readGateInput(DomainMigrationRollout.enabled, relatedOriginsSupported))
|
||||
) {
|
||||
const passkeysSupported = await desktopPasskeysSupported();
|
||||
if (navigating || !passkeysSupported || !shouldStartDomainMigration(readGateInput(DomainMigrationRollout.enabled))) {
|
||||
return;
|
||||
}
|
||||
navigating = true;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {solveChallengeWorkers} from 'altcha-lib';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
|
||||
export type AltchaChallenge = Challenge;
|
||||
|
||||
const MAX_SOLVER_WORKERS = 8;
|
||||
const SOLVE_TIMEOUT_MS = 120_000;
|
||||
|
||||
function createSolverWorker(): Worker {
|
||||
return new Worker(
|
||||
new URL(/* webpackChunkName: "altcha-solver.worker" */ './AltchaSolverWorker.ts', import.meta.url),
|
||||
{
|
||||
type: 'module',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function readAltchaChallenge(body: unknown): AltchaChallenge | null {
|
||||
if (typeof body !== 'object' || body === null) return null;
|
||||
const {captcha_provider: provider, altcha_challenge: challenge} = body as Record<string, unknown>;
|
||||
if (provider !== 'altcha' || typeof challenge !== 'object' || challenge === null) return null;
|
||||
const {parameters, signature} = challenge as Record<string, unknown>;
|
||||
if (typeof parameters !== 'object' || parameters === null || typeof signature !== 'string') return null;
|
||||
return challenge as AltchaChallenge;
|
||||
}
|
||||
|
||||
export async function solveAltchaChallenge(
|
||||
challenge: AltchaChallenge,
|
||||
controller: AbortController,
|
||||
): Promise<string | null> {
|
||||
const solution = await solveChallengeWorkers({
|
||||
challenge,
|
||||
concurrency: Math.min(MAX_SOLVER_WORKERS, navigator.hardwareConcurrency || 2),
|
||||
controller,
|
||||
createWorker: createSolverWorker,
|
||||
timeout: SOLVE_TIMEOUT_MS,
|
||||
});
|
||||
if (!solution) return null;
|
||||
return btoa(
|
||||
JSON.stringify({challenge: {parameters: challenge.parameters, signature: challenge.signature}, solution}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {deriveKey} from 'altcha-lib/algorithms/web/pbkdf2';
|
||||
import {handler} from 'altcha-lib/workers/shared';
|
||||
|
||||
handler({deriveKey});
|
||||
@@ -0,0 +1,16 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type AltchaChallenge, solveAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
|
||||
import styles from '@app/features/auth/components/AltchaVerification.module.css';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
const logger = new Logger('AltchaVerification');
|
||||
|
||||
interface AltchaVerificationProps {
|
||||
challenge: AltchaChallenge;
|
||||
onVerify: (token: string) => void;
|
||||
}
|
||||
|
||||
export function AltchaVerification({challenge, onVerify}: AltchaVerificationProps) {
|
||||
const onVerifyRef = useRef(onVerify);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
onVerifyRef.current = onVerify;
|
||||
}, [onVerify]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setFailed(false);
|
||||
solveAltchaChallenge(challenge, controller).then(
|
||||
(token) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (token) {
|
||||
onVerifyRef.current(token);
|
||||
} else {
|
||||
setFailed(true);
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
logger.error('ALTCHA solve failed:', error);
|
||||
setFailed(true);
|
||||
},
|
||||
);
|
||||
return () => controller.abort();
|
||||
}, [challenge, attempt]);
|
||||
const handleRetry = useCallback(() => setAttempt((value) => value + 1), []);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.altcha-verification.failed">
|
||||
<p className={styles.text} data-flx="auth.altcha-verification.failed-text">
|
||||
<Trans>Your browser couldn't finish the check.</Trans>
|
||||
</p>
|
||||
<Button small variant="secondary" onClick={handleRetry} data-flx="auth.altcha-verification.retry-button">
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.container} role="status" aria-live="polite" data-flx="auth.altcha-verification.solving">
|
||||
<Spinner data-flx="auth.altcha-verification.spinner" />
|
||||
<p className={styles.text} data-flx="auth.altcha-verification.solving-text">
|
||||
<Trans>Checking your browser. This takes a few seconds.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type AltchaChallenge, readAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
|
||||
import {CaptchaModal, type CaptchaType} from '@app/features/auth/components/modals/CaptchaModal';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
|
||||
@@ -69,7 +70,7 @@ class CaptchaInterceptorState {
|
||||
return code === 'CAPTCHA_REQUIRED' || code === 'INVALID_CAPTCHA';
|
||||
}
|
||||
|
||||
private showCaptchaModal(): Promise<CaptchaResult> {
|
||||
private showCaptchaModal(altchaChallenge: AltchaChallenge | null): Promise<CaptchaResult> {
|
||||
if (this.pendingPromise) {
|
||||
this.pendingPromise.reject(new Error('Captcha cancelled'));
|
||||
this.pendingPromise = null;
|
||||
@@ -95,6 +96,7 @@ class CaptchaInterceptorState {
|
||||
};
|
||||
const CaptchaModalWrapper = observer(() => (
|
||||
<CaptchaModal
|
||||
altchaChallenge={altchaChallenge}
|
||||
onVerify={handleVerify}
|
||||
onCancel={handleCancel}
|
||||
error={this.state.error}
|
||||
@@ -119,7 +121,7 @@ class CaptchaInterceptorState {
|
||||
const errorMessage = replyMessage(reply.body) || i18n._(CAPTCHA_VERIFICATION_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR);
|
||||
this.state.setError(errorMessage);
|
||||
this.state.setIsVerifying(false);
|
||||
const promise = this.showCaptchaModal()
|
||||
const promise = this.showCaptchaModal(readAltchaChallenge(reply.body))
|
||||
.then((captchaResult) => {
|
||||
this.state.setError(null);
|
||||
this.state.setIsVerifying(false);
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isPasskeyBridgeAvailable} from '@app/features/auth/utils/PasskeyBridge';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {WarningAlert} from '@app/features/ui/warning_alert/WarningAlert';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {PasswordIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
|
||||
const USE_A_PASSWORD_MANAGER_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Use a password manager passkey',
|
||||
comment:
|
||||
'Button that runs the passkey prompt in a small pop-up window on the old web address, so password manager extensions can find passkeys saved there.',
|
||||
});
|
||||
const PASSWORD_MANAGER_PASSKEY_SUGGESTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Password managers may not offer your passkey on this web address. Try it in a pop-up window on the old address instead.',
|
||||
comment:
|
||||
'Shown after a passkey prompt fails, above the button that retries the passkey prompt in a pop-up window on the old web address.',
|
||||
});
|
||||
|
||||
interface PasswordManagerPasskeyActionProps {
|
||||
suggested: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
export function PasswordManagerPasskeyAction({suggested, disabled, onClick}: PasswordManagerPasskeyActionProps) {
|
||||
const {i18n} = useLingui();
|
||||
if (!isPasskeyBridgeAvailable()) {
|
||||
return null;
|
||||
}
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
fitContainer
|
||||
variant={suggested ? 'primary' : 'secondary'}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
leftIcon={<PasswordIcon size={16} data-flx="auth.password-manager-passkey-action.icon" />}
|
||||
data-flx="auth.password-manager-passkey-action.button"
|
||||
>
|
||||
{i18n._(USE_A_PASSWORD_MANAGER_PASSKEY_DESCRIPTOR)}
|
||||
</Button>
|
||||
);
|
||||
if (!suggested) {
|
||||
return button;
|
||||
}
|
||||
return (
|
||||
<WarningAlert actions={button} data-flx="auth.password-manager-passkey-action.suggestion">
|
||||
{i18n._(PASSWORD_MANAGER_PASSKEY_SUGGESTION_DESCRIPTOR)}
|
||||
</WarningAlert>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import type {AltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
|
||||
import {AltchaVerification} from '@app/features/auth/components/AltchaVerification';
|
||||
import styles from '@app/features/auth/components/modals/CaptchaModal.module.css';
|
||||
import {TurnstileWidget} from '@app/features/auth/components/TurnstileWidget';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -18,7 +20,7 @@ const VERIFY_YOU_RE_HUMAN_DESCRIPTOR = msg({
|
||||
});
|
||||
const logger = new Logger('CaptchaModal');
|
||||
|
||||
export type CaptchaType = 'turnstile' | 'hcaptcha';
|
||||
export type CaptchaType = 'turnstile' | 'hcaptcha' | 'altcha';
|
||||
|
||||
interface HCaptchaComponentProps {
|
||||
sitekey: string;
|
||||
@@ -35,16 +37,26 @@ interface CaptchaModalProps {
|
||||
onVerify: (token: string, captchaType: CaptchaType) => void;
|
||||
onCancel?: () => void;
|
||||
preferredType?: CaptchaType;
|
||||
altchaChallenge?: AltchaChallenge | null;
|
||||
error?: string | null;
|
||||
isVerifying?: boolean;
|
||||
closeOnVerify?: boolean;
|
||||
}
|
||||
|
||||
export const CaptchaModal = observer(
|
||||
({onVerify, onCancel, preferredType, error, isVerifying, closeOnVerify = true}: CaptchaModalProps) => {
|
||||
({
|
||||
onVerify,
|
||||
onCancel,
|
||||
preferredType,
|
||||
altchaChallenge,
|
||||
error,
|
||||
isVerifying,
|
||||
closeOnVerify = true,
|
||||
}: CaptchaModalProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const hcaptchaRef = useRef<HCaptcha>(null);
|
||||
const [captchaType, setCaptchaType] = useState<CaptchaType>(() => {
|
||||
if (altchaChallenge) return 'altcha';
|
||||
if (preferredType) return preferredType;
|
||||
if (RuntimeConfig.captchaProvider === 'turnstile' && RuntimeConfig.turnstileSiteKey) {
|
||||
return 'turnstile';
|
||||
@@ -123,7 +135,13 @@ export const CaptchaModal = observer(
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.captchaContainer} data-flx="auth.captcha-modal.captcha-container">
|
||||
{captchaType === 'turnstile' ? (
|
||||
{captchaType === 'altcha' && altchaChallenge ? (
|
||||
<AltchaVerification
|
||||
challenge={altchaChallenge}
|
||||
onVerify={handleVerify}
|
||||
data-flx="auth.captcha-modal.altcha-verification"
|
||||
/>
|
||||
) : captchaType === 'turnstile' ? (
|
||||
<TurnstileWidget
|
||||
sitekey={RuntimeConfig.turnstileSiteKey ?? ''}
|
||||
onVerify={handleVerify}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import {describePasskeyBridgeFailure, shouldSuggestPasskeyBridge} from '@app/features/auth/utils/PasskeyBridge';
|
||||
import {
|
||||
PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR,
|
||||
PasskeyDomainUnsupportedError,
|
||||
} from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -16,8 +10,6 @@ import * as FormUtils from '@app/lib/forms';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
|
||||
const NAME_PASSKEY_FORM_DESCRIPTOR = msg({
|
||||
@@ -41,58 +33,16 @@ interface FormInputs {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PasskeyNameModalProps {
|
||||
onSubmit: (name: string) => void | Promise<void>;
|
||||
onSubmitWithPasswordManager?: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager}: PasskeyNameModalProps) => {
|
||||
export const PasskeyNameModal = observer(({onSubmit}: {onSubmit: (name: string) => void | Promise<void>}) => {
|
||||
const {i18n} = useLingui();
|
||||
const form = useForm<FormInputs>();
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const [passkeyBridgeSubmitting, setPasskeyBridgeSubmitting] = useState(false);
|
||||
const handlePasswordManagerSubmit = (
|
||||
event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (!onSubmitWithPasswordManager || passkeyBridgeSubmitting || form.formState.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
if (event.currentTarget.form?.reportValidity() === false) {
|
||||
return;
|
||||
}
|
||||
const submission = onSubmitWithPasswordManager(form.getValues('name').trim());
|
||||
form.clearErrors('name');
|
||||
setPasskeyBridgeSubmitting(true);
|
||||
submission
|
||||
.then(() => {
|
||||
ModalCommands.pop();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof HttpError) {
|
||||
FormUtils.handleError(i18n, form, error, 'name');
|
||||
return;
|
||||
}
|
||||
const descriptor = describePasskeyBridgeFailure(error);
|
||||
if (descriptor) {
|
||||
form.setError('name', {type: 'server', message: i18n._(descriptor)});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setPasskeyBridgeSubmitting(false);
|
||||
});
|
||||
};
|
||||
const handleSubmit = async (data: FormInputs) => {
|
||||
try {
|
||||
await onSubmit(data.name.trim());
|
||||
ModalCommands.pop();
|
||||
} catch (error) {
|
||||
if (onSubmitWithPasswordManager && shouldSuggestPasskeyBridge(error)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
}
|
||||
if (error instanceof HttpError) {
|
||||
FormUtils.handleError(i18n, form, error, 'name');
|
||||
} else if (error instanceof PasskeyDomainUnsupportedError) {
|
||||
form.setError('name', {type: 'server', message: i18n._(PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR)});
|
||||
} else {
|
||||
form.setError('name', {type: 'server', message: FormUtils.extractErrorMessage(i18n, error)});
|
||||
}
|
||||
@@ -123,14 +73,6 @@ export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager
|
||||
required={true}
|
||||
type="text"
|
||||
/>
|
||||
{onSubmitWithPasswordManager && (
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passkeyBridgeSuggested}
|
||||
disabled={form.formState.isSubmitting || passkeyBridgeSubmitting}
|
||||
onClick={handlePasswordManagerSubmit}
|
||||
data-flx="auth.passkey-name-modal.password-manager-passkey-action"
|
||||
/>
|
||||
)}
|
||||
</Modal.ContentLayout>
|
||||
</Modal.Content>
|
||||
<Modal.Footer data-flx="auth.passkey-name-modal.modal-footer">
|
||||
@@ -140,7 +82,6 @@ export const PasskeyNameModal = observer(({onSubmit, onSubmitWithPasswordManager
|
||||
<Button
|
||||
type="submit"
|
||||
submitting={form.formState.isSubmitting}
|
||||
disabled={passkeyBridgeSubmitting}
|
||||
data-flx="auth.passkey-name-modal.button.submit"
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
|
||||
@@ -51,3 +51,9 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.passkeyHint {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
@@ -3,25 +3,37 @@
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import styles from '@app/features/auth/components/modals/SudoVerificationModal.module.css';
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import SudoPrompt, {SudoVerificationMethod} from '@app/features/auth/state/SudoPrompt';
|
||||
import {
|
||||
describePasskeyBridgeFailure,
|
||||
runPasskeyViaBridge,
|
||||
shouldSuggestPasskeyBridge,
|
||||
} from '@app/features/auth/utils/PasskeyBridge';
|
||||
isPasskeyCeremonyDismissed,
|
||||
PasskeyBridgeSudoLink,
|
||||
runPasskeyBridgeNativeSudo,
|
||||
} from '@app/features/auth/passkey_migration/PasskeyLegacyCeremony';
|
||||
import PasskeyMigration from '@app/features/auth/passkey_migration/PasskeyMigration';
|
||||
import {isPasskeyMigrationOrigin} from '@app/features/auth/passkey_migration/PasskeyMigrationOrigin';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Sudo from '@app/features/auth/state/AuthSudo';
|
||||
import SudoPrompt, {SUDO_MODAL_KEY, SudoVerificationMethod} from '@app/features/auth/state/SudoPrompt';
|
||||
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import {PASSWORD_DESCRIPTOR, VERIFY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {
|
||||
COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR,
|
||||
PASSWORD_DESCRIPTOR,
|
||||
VERIFY_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import buttonStyles from '@app/features/ui/button/Button.module.css';
|
||||
import {Form} from '@app/features/ui/components/form/Form';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import WebAuthnCredentials from '@app/features/user/state/WebAuthnCredentials';
|
||||
import * as FormUtils from '@app/lib/forms';
|
||||
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import type {PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
@@ -33,10 +45,6 @@ const PASSKEYS_REQUIRE_A_SIGNED_MACOS_BUNDLE_WITH_A_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Sudo (re-auth) modal body shown on unsigned macOS desktop bundles where passkeys cannot work. Direct the user to install the signed client.',
|
||||
});
|
||||
const COULDN_T_VERIFY_WITH_PASSKEY_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
|
||||
message: "Couldn't verify with passkey. Try again.",
|
||||
comment: 'Sudo (re-auth) modal toast error shown when passkey verification fails. Keep plain.',
|
||||
});
|
||||
const ENTER_YOUR_PASSWORD_DESCRIPTOR = msg({
|
||||
message: 'Enter your password.',
|
||||
comment: 'Body text in the authentication sudo verification modal. Keep the tone plain and specific.',
|
||||
@@ -72,6 +80,11 @@ const BACKUP_CODE_DESCRIPTOR = msg({
|
||||
'Label and placeholder for the code field in the authentication sudo verification modal when the only code the account can use is a backup code.',
|
||||
});
|
||||
const VERIFICATION_FAILED_DESCRIPTOR = msg({message: 'Verification failed'});
|
||||
const FINISH_IN_THE_NEW_TAB_DESCRIPTOR = msg({
|
||||
message: 'Finish in the new tab. If you closed it, press Continue with passkey again.',
|
||||
comment:
|
||||
'Sudo (re-auth) modal hint shown after the passkey button opened a new tab to confirm the passkey. "Continue with passkey" is the button label. Keep plain.',
|
||||
});
|
||||
const logger = new Logger('SudoVerificationModal');
|
||||
|
||||
interface FormInputs {
|
||||
@@ -83,14 +96,24 @@ const isMacAppIdentifierError = (error: unknown): boolean => {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
return message.toLowerCase().includes('application identifier');
|
||||
};
|
||||
const holdsPasskeyFor = (matches: (rpId: string) => boolean): boolean =>
|
||||
WebAuthnCredentials.credentials.some((credential) => matches(credential.rp_id));
|
||||
const holdsMigratedPasskey = (): boolean => holdsPasskeyFor((rpId) => rpId === PASSKEY_MIGRATION_RP_ID);
|
||||
const holdsLegacyPasskey = (): boolean => holdsPasskeyFor((rpId) => rpId !== PASSKEY_MIGRATION_RP_ID);
|
||||
const SudoVerificationModal: React.FC = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const {availableMethods, isVerifying, verificationFailed, rawError, lastUsedMfaMethod} = SudoPrompt;
|
||||
const form = useForm<FormInputs>({defaultValues: {password: '', totp: ''}});
|
||||
const [webAuthnInFlight, setWebAuthnInFlight] = useState(false);
|
||||
const [webAuthnError, setWebAuthnError] = useState<string | null>(null);
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const autoTriggeredRef = useRef(false);
|
||||
const [legacyLinkUrl, setLegacyLinkUrl] = useState<string | null>(null);
|
||||
const [legacyLinkActive, setLegacyLinkActive] = useState(false);
|
||||
const [legacyLinkFollowed, setLegacyLinkFollowed] = useState(false);
|
||||
const legacyLinkRef = useRef<PasskeyBridgeSudoLink | null>(null);
|
||||
const preferLegacyRef = useRef(false);
|
||||
const openRef = useRef(true);
|
||||
const userIdAtOpenRef = useRef(AccountManager.currentUserId);
|
||||
const showPasskey = availableMethods.webauthn;
|
||||
const showTotp = availableMethods.totp;
|
||||
const backupCodeOnly = !showTotp && availableMethods.backupCodes;
|
||||
@@ -113,12 +136,59 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
}
|
||||
setWebAuthnInFlight(false);
|
||||
}, [form, verificationFailed, rawError, i18n, i18n.locale, showPassword, showCode]);
|
||||
const finishLegacySudo = (sudoToken: string) => {
|
||||
if (!openRef.current || AccountManager.currentUserId !== userIdAtOpenRef.current) return;
|
||||
Sudo.setToken(sudoToken);
|
||||
PasskeyMigration.checkAfterSudo(SUDO_MODAL_KEY);
|
||||
SudoPrompt.submit({});
|
||||
};
|
||||
const startLegacyLink = () => {
|
||||
if (legacyLinkRef.current === null) {
|
||||
legacyLinkRef.current = new PasskeyBridgeSudoLink({
|
||||
onLink: (url) => {
|
||||
setLegacyLinkUrl(url);
|
||||
if (url === null) {
|
||||
setLegacyLinkFollowed(false);
|
||||
}
|
||||
},
|
||||
onCompleted: finishLegacySudo,
|
||||
onError: (error) => {
|
||||
logger.error('WebAuthn verification in a new tab failed', error);
|
||||
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR));
|
||||
},
|
||||
});
|
||||
}
|
||||
setLegacyLinkActive(true);
|
||||
void legacyLinkRef.current.start();
|
||||
};
|
||||
useEffect(() => {
|
||||
if (showPasskey && isPasskeyMigrationOrigin() && !Platform.isElectron && !holdsMigratedPasskey()) {
|
||||
startLegacyLink();
|
||||
}
|
||||
return () => {
|
||||
openRef.current = false;
|
||||
legacyLinkRef.current?.dispose();
|
||||
};
|
||||
}, []);
|
||||
const handleWebAuthn = async () => {
|
||||
if (webAuthnInFlight || isVerifying) return;
|
||||
setWebAuthnError(null);
|
||||
form.clearErrors();
|
||||
setWebAuthnInFlight(true);
|
||||
const migrationOrigin = isPasskeyMigrationOrigin();
|
||||
const runsLegacyNatively =
|
||||
migrationOrigin && Platform.isElectron && (preferLegacyRef.current || !holdsMigratedPasskey());
|
||||
try {
|
||||
if (runsLegacyNatively) {
|
||||
preferLegacyRef.current = false;
|
||||
const result = await runPasskeyBridgeNativeSudo();
|
||||
if (result.status === 'completed') {
|
||||
finishLegacySudo(result.sudo_token);
|
||||
return;
|
||||
}
|
||||
setWebAuthnInFlight(false);
|
||||
return;
|
||||
}
|
||||
await WebAuthnUtils.assertWebAuthnSupported();
|
||||
const optionsResponse = await http.post<{challenge: string}>(Endpoints.SUDO_WEBAUTHN_OPTIONS);
|
||||
const credential = await WebAuthnUtils.performAuthentication(optionsResponse.body);
|
||||
@@ -130,49 +200,22 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
} catch (err) {
|
||||
logger.error('WebAuthn verification failed', err);
|
||||
setWebAuthnInFlight(false);
|
||||
if (migrationOrigin && !runsLegacyNatively && isPasskeyCeremonyDismissed(err) && holdsLegacyPasskey()) {
|
||||
if (!Platform.isElectron) {
|
||||
startLegacyLink();
|
||||
return;
|
||||
}
|
||||
preferLegacyRef.current = true;
|
||||
}
|
||||
if (isMacAppIdentifierError(err)) {
|
||||
setWebAuthnError(i18n._(PASSKEYS_REQUIRE_A_SIGNED_MACOS_BUNDLE_WITH_A_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
if (shouldSuggestPasskeyBridge(err)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
return;
|
||||
}
|
||||
if (err instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
|
||||
setWebAuthnError(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_PLEASE_TRY_AGAIN_DESCRIPTOR));
|
||||
setWebAuthnError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR));
|
||||
}
|
||||
};
|
||||
const handlePasskeyBridge = () => {
|
||||
if (webAuthnInFlight || isVerifying) return;
|
||||
const options = http
|
||||
.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.SUDO_WEBAUTHN_OPTIONS)
|
||||
.then((response) => response.body);
|
||||
const credential = runPasskeyViaBridge('authenticate', options);
|
||||
setWebAuthnError(null);
|
||||
form.clearErrors();
|
||||
setWebAuthnInFlight(true);
|
||||
Promise.all([options, credential])
|
||||
.then(([resolvedOptions, resolvedCredential]) => {
|
||||
SudoPrompt.submit({
|
||||
mfa_method: SudoVerificationMethod.WEBAUTHN,
|
||||
webauthn_challenge: resolvedOptions.challenge,
|
||||
webauthn_response: resolvedCredential,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.error('WebAuthn verification in the pop-up window failed', err);
|
||||
setWebAuthnInFlight(false);
|
||||
const descriptor = describePasskeyBridgeFailure(err);
|
||||
if (descriptor) {
|
||||
setWebAuthnError(i18n._(descriptor));
|
||||
}
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (autoTriggeredRef.current) return;
|
||||
if (autoTriggeredRef.current || legacyLinkRef.current !== null) return;
|
||||
if (!showPasskey || showPassword || showCode) return;
|
||||
if (lastUsedMfaMethod && lastUsedMfaMethod !== 'webauthn') return;
|
||||
autoTriggeredRef.current = true;
|
||||
@@ -263,7 +306,39 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
|
||||
{showPasskey && (
|
||||
<>
|
||||
{webAuthnInFlight ? (
|
||||
{legacyLinkActive && legacyLinkUrl !== null ? (
|
||||
<FocusRing offset={-2} data-flx="auth.sudo-verification-modal.focus-ring.legacy-link">
|
||||
<a
|
||||
href={legacyLinkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
setWebAuthnError(null);
|
||||
setLegacyLinkFollowed(true);
|
||||
}}
|
||||
className={clsx(
|
||||
buttonStyles.button,
|
||||
buttonStyles[showCode || showPassword ? 'secondary' : 'primary'],
|
||||
buttonStyles.fitContainer,
|
||||
)}
|
||||
data-flx="auth.sudo-verification-modal.link.web-authn"
|
||||
>
|
||||
<Trans>Continue with passkey</Trans>
|
||||
</a>
|
||||
</FocusRing>
|
||||
) : legacyLinkActive ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={startLegacyLink}
|
||||
submitting={webAuthnError === null}
|
||||
disabled={isVerifying}
|
||||
fitContainer
|
||||
variant={showCode || showPassword ? 'secondary' : 'primary'}
|
||||
data-flx="auth.sudo-verification-modal.button.web-authn-starting"
|
||||
>
|
||||
<Trans>Continue with passkey</Trans>
|
||||
</Button>
|
||||
) : webAuthnInFlight ? (
|
||||
<div
|
||||
className={styles.passkeyVerifying}
|
||||
role="status"
|
||||
@@ -287,13 +362,10 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
<Trans>Continue with passkey</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{!webAuthnInFlight && (
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passkeyBridgeSuggested}
|
||||
disabled={isVerifying}
|
||||
onClick={handlePasskeyBridge}
|
||||
data-flx="auth.sudo-verification-modal.password-manager-passkey-action"
|
||||
/>
|
||||
{legacyLinkActive && legacyLinkFollowed && !webAuthnError && (
|
||||
<p className={styles.passkeyHint} data-flx="auth.sudo-verification-modal.passkey-hint">
|
||||
{i18n._(FINISH_IN_THE_NEW_TAB_DESCRIPTOR)}
|
||||
</p>
|
||||
)}
|
||||
{webAuthnError && (
|
||||
<p className={styles.formError} role="alert" data-flx="auth.sudo-verification-modal.form-error">
|
||||
|
||||
+1
@@ -141,6 +141,7 @@ export const OAuthAuthorizeFlowPanel: React.FC<OAuthAuthorizeFlowPanelProps> = o
|
||||
<OAuthScopesStep
|
||||
authParams={authParams}
|
||||
botInviteWithoutRedirect={flow.botInviteWithoutRedirect}
|
||||
cannotSubmit={flow.cannotSubmit}
|
||||
clientLabel={flow.clientLabel}
|
||||
hasNextStep={flow.hasNextStep}
|
||||
hasPreviousStep={flow.hasPreviousStep}
|
||||
|
||||
+12
-3
@@ -29,6 +29,7 @@ const REQUIRED_DESCRIPTOR = msg({
|
||||
interface OAuthScopesStepProps {
|
||||
authParams: AuthorizeParams;
|
||||
botInviteWithoutRedirect: boolean;
|
||||
cannotSubmit: boolean;
|
||||
clientLabel: string;
|
||||
hasNextStep: boolean;
|
||||
hasPreviousStep: boolean;
|
||||
@@ -51,6 +52,7 @@ interface OAuthScopesStepProps {
|
||||
export const OAuthScopesStep: React.FC<OAuthScopesStepProps> = ({
|
||||
authParams,
|
||||
botInviteWithoutRedirect,
|
||||
cannotSubmit,
|
||||
clientLabel,
|
||||
hasNextStep,
|
||||
hasPreviousStep,
|
||||
@@ -151,10 +153,16 @@ export const OAuthScopesStep: React.FC<OAuthScopesStepProps> = ({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{scopesAdjusted && (
|
||||
<div className={styles.caution} data-flx="auth.o-auth-authorize-page.caution--2">
|
||||
<Trans>Turning off scopes may prevent the app from working correctly.</Trans>
|
||||
{scopes.length > 0 && selectedScopes.size === 0 ? (
|
||||
<div className={styles.caution} data-flx="auth.o-auth-authorize-page.caution--no-scopes">
|
||||
<Trans>Turn on at least one scope to authorize this app.</Trans>
|
||||
</div>
|
||||
) : (
|
||||
scopesAdjusted && (
|
||||
<div className={styles.caution} data-flx="auth.o-auth-authorize-page.caution--2">
|
||||
<Trans>Turning off scopes may prevent the app from working correctly.</Trans>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,6 +173,7 @@ export const OAuthScopesStep: React.FC<OAuthScopesStepProps> = ({
|
||||
showRedirectNotice={showRedirectNotice}
|
||||
hasPreviousStep={hasPreviousStep}
|
||||
hasNextStep={hasNextStep}
|
||||
authorizeDisabled={cannotSubmit}
|
||||
onAuthorize={onAuthorize}
|
||||
onBack={onBack}
|
||||
onCancel={onCancel}
|
||||
|
||||
+3
-3
@@ -306,7 +306,7 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
() => destinations.options.find((option) => option.value === selectedDestinationKey) ?? null,
|
||||
[destinations.options, selectedDestinationKey],
|
||||
);
|
||||
const cannotSubmit = hasBotScope && !selectedDestination;
|
||||
const cannotSubmit = scopeSelection.selected.size === 0 || (hasBotScope && !selectedDestination);
|
||||
const needsPermissionsStep =
|
||||
hasBotScope && selectedDestination?.kind !== 'group_dm' && permissionSelection.requestedKeys.length > 0;
|
||||
const hasRequestedBotPermissions =
|
||||
@@ -350,9 +350,9 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
setSubmitError(null);
|
||||
setSubmitting('approve');
|
||||
try {
|
||||
const scopeToSend = scopeSelection.toScopeString() || params.scope;
|
||||
const scopeToSend = scopeSelection.toScopeString();
|
||||
const sendsBotScope = scopeToSend.split(/[\s+]+/).includes('bot');
|
||||
if (sendsBotScope && !selectedDestination) {
|
||||
if (!scopeToSend || (sendsBotScope && !selectedDestination)) {
|
||||
setSubmitting(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {ConnectedHandoffApprovalFlow} from '@app/features/auth/flow/HandoffAppro
|
||||
import IpAuthorizationScreen from '@app/features/auth/flow/IpAuthorizationScreen';
|
||||
import {useAuthCardPresentation} from '@app/features/auth/flow/useAuthCardPresentation';
|
||||
import {useLoginFormController} from '@app/features/auth/hooks/useLoginFlow';
|
||||
import {usePasskeyBridgeReturn} from '@app/features/auth/passkey_migration/usePasskeyBridgeReturn';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import {
|
||||
type IpAuthorizationChallenge,
|
||||
@@ -35,7 +36,11 @@ import {
|
||||
startSsoLogin,
|
||||
} from '@app/features/auth/state/AuthFlow';
|
||||
import {shouldOfferOldAppSignIn} from '@app/features/auth/utils/OldAppSignIn';
|
||||
import {NEED_ACCOUNT_DESCRIPTOR, SIGN_IN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {
|
||||
COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR,
|
||||
NEED_ACCOUNT_DESCRIPTOR,
|
||||
SIGN_IN_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {type Account, SessionExpiredError} from '@app/features/platform/state/AuthSession';
|
||||
@@ -162,28 +167,29 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
},
|
||||
[desktopHandoff, handoff, onLoginComplete],
|
||||
);
|
||||
const {
|
||||
form,
|
||||
isLoading,
|
||||
fieldErrors,
|
||||
handlePasskeyLogin,
|
||||
handlePasskeyBrowserLogin,
|
||||
handlePasskeyBridgeLogin,
|
||||
passkeyBridgeSuggested,
|
||||
isPasskeyLoading,
|
||||
} = useLoginFormController({
|
||||
const {form, isLoading, fieldErrors, handlePasskeyLogin, handlePasskeyBrowserLogin, isPasskeyLoading} =
|
||||
useLoginFormController({
|
||||
redirectPath,
|
||||
inviteCode,
|
||||
onLoginSuccess: handleLoginSuccess,
|
||||
onRequireMfa: (challenge) => {
|
||||
AuthenticationCommands.setMfaTicket(challenge);
|
||||
},
|
||||
onRequireIpAuthorization: (challenge) => {
|
||||
setIpAuthChallenge(challenge);
|
||||
},
|
||||
});
|
||||
const isPasskeyBridgeRedeeming = usePasskeyBridgeReturn({
|
||||
redirectPath,
|
||||
inviteCode,
|
||||
onLoginSuccess: handleLoginSuccess,
|
||||
onRequireMfa: (challenge) => {
|
||||
AuthenticationCommands.setMfaTicket(challenge);
|
||||
},
|
||||
onRequireIpAuthorization: (challenge) => {
|
||||
setIpAuthChallenge(challenge);
|
||||
onRequireMfa: AuthenticationCommands.setMfaTicket,
|
||||
onFailure: () => {
|
||||
setSwitchError(i18n._(COULDN_T_VERIFY_WITH_PASSKEY_DESCRIPTOR));
|
||||
},
|
||||
});
|
||||
const showBrowserPasskey = IS_DEV || isDesktop();
|
||||
const passkeyControlsDisabled = isLoading || Boolean(form.isSubmitting) || isPasskeyLoading;
|
||||
const passkeyControlsDisabled =
|
||||
isLoading || Boolean(form.isSubmitting) || isPasskeyLoading || isPasskeyBridgeRedeeming;
|
||||
const offerOldAppSignIn = useMemo(
|
||||
() =>
|
||||
!desktopHandoff &&
|
||||
@@ -414,7 +420,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
</AuthRouterLink>
|
||||
) : null
|
||||
}
|
||||
disableSubmit={isPasskeyLoading}
|
||||
disableSubmit={isPasskeyLoading || isPasskeyBridgeRedeeming}
|
||||
data-flx="auth.flow.auth-login-layout.auth-login-email-password-form"
|
||||
/>
|
||||
<AuthLoginDivider
|
||||
@@ -433,8 +439,6 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
onPasskeyLogin={handlePasskeyLogin}
|
||||
showBrowserOption={showBrowserPasskey}
|
||||
onBrowserLogin={handlePasskeyBrowserLogin}
|
||||
onPasswordManagerLogin={handlePasskeyBridgeLogin}
|
||||
passwordManagerSuggested={passkeyBridgeSuggested}
|
||||
browserLabel={i18n._(SIGN_IN_VIA_BROWSER_DESCRIPTOR)}
|
||||
data-flx="auth.flow.auth-login-layout.auth-login-passkey-actions"
|
||||
/>
|
||||
|
||||
@@ -30,9 +30,6 @@
|
||||
}
|
||||
|
||||
.webauthnSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {MFA_CODE_DIGIT_COUNT} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import FormField from '@app/features/auth/flow/AuthFormField';
|
||||
import styles from '@app/features/auth/flow/MfaScreen.module.css';
|
||||
import {useAuthCardPresentation} from '@app/features/auth/flow/useAuthCardPresentation';
|
||||
@@ -54,16 +53,7 @@ interface MfaScreenProps {
|
||||
|
||||
const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const {
|
||||
form,
|
||||
isLoading,
|
||||
fieldErrors,
|
||||
handleWebAuthn,
|
||||
handlePasskeyBridge,
|
||||
passkeyBridgeSuggested,
|
||||
isWebAuthnLoading,
|
||||
supports,
|
||||
} = useMfaController({
|
||||
const {form, isLoading, fieldErrors, handleWebAuthn, isWebAuthnLoading, supports} = useMfaController({
|
||||
ticket: challenge.ticket,
|
||||
methods: {totp: challenge.totp, webauthn: challenge.webauthn, backupCodes: challenge.backupCodes},
|
||||
inviteCode,
|
||||
@@ -138,12 +128,6 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps)
|
||||
>
|
||||
{i18n._(isCodePrimary ? TRY_SECURITY_KEY_INSTEAD_DESCRIPTOR : SECURITY_KEY_OR_PASSKEY_DESCRIPTOR)}
|
||||
</Button>
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passkeyBridgeSuggested}
|
||||
disabled={isWebAuthnLoading}
|
||||
onClick={handlePasskeyBridge}
|
||||
data-flx="auth.flow.mfa-screen.password-manager-passkey-action"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.footerButtons} data-flx="auth.flow.mfa-screen.footer-buttons">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PasswordManagerPasskeyAction} from '@app/features/auth/components/PasswordManagerPasskeyAction';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {BrowserIcon, KeyIcon} from '@phosphor-icons/react';
|
||||
@@ -51,8 +50,6 @@ interface Props {
|
||||
onPasskeyLogin: () => void;
|
||||
showBrowserOption: boolean;
|
||||
onBrowserLogin?: () => void;
|
||||
onPasswordManagerLogin?: () => void;
|
||||
passwordManagerSuggested?: boolean;
|
||||
primaryLabel?: React.ReactNode;
|
||||
browserLabel?: React.ReactNode;
|
||||
}
|
||||
@@ -63,8 +60,6 @@ export default function AuthLoginPasskeyActions({
|
||||
onPasskeyLogin,
|
||||
showBrowserOption,
|
||||
onBrowserLogin,
|
||||
onPasswordManagerLogin,
|
||||
passwordManagerSuggested = false,
|
||||
primaryLabel = <Trans>Sign in with a passkey</Trans>,
|
||||
browserLabel = <Trans>Sign in via browser</Trans>,
|
||||
}: Props) {
|
||||
@@ -96,14 +91,6 @@ export default function AuthLoginPasskeyActions({
|
||||
{browserLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{onPasswordManagerLogin ? (
|
||||
<PasswordManagerPasskeyAction
|
||||
suggested={passwordManagerSuggested}
|
||||
disabled={disabled}
|
||||
onClick={onPasswordManagerLogin}
|
||||
data-flx="auth.flow.auth-login-core.auth-login-passkey-actions.password-manager-passkey-action"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
import {showBrowserLoginHandoffModal} from '@app/features/auth/flow/BrowserLoginHandoffModal';
|
||||
import {useAuthForm} from '@app/features/auth/hooks/useAuthForm';
|
||||
import {CaptchaCancelledError} from '@app/features/auth/hooks/useCaptcha';
|
||||
import {
|
||||
isPasskeyCeremonyDismissed,
|
||||
runPasskeyBridgeNativeLogin,
|
||||
startPasskeyBridgePageLogin,
|
||||
} from '@app/features/auth/passkey_migration/PasskeyLegacyCeremony';
|
||||
import {readPasskeyLoginRoute, writePasskeyLoginRoute} from '@app/features/auth/passkey_migration/PasskeyLoginRoute';
|
||||
import {isPasskeyMigrationOrigin, rpIdMatchesPage} from '@app/features/auth/passkey_migration/PasskeyMigrationOrigin';
|
||||
import {
|
||||
authenticateMfaWithWebAuthn,
|
||||
authenticateWithWebAuthn,
|
||||
@@ -15,19 +22,13 @@ import {
|
||||
loginWithMfaCode,
|
||||
loginWithPassword,
|
||||
type MfaChallenge,
|
||||
toLoginSuccessPayload,
|
||||
} from '@app/features/auth/state/AuthFlow';
|
||||
import {
|
||||
describePasskeyBridgeFailure,
|
||||
runPasskeyViaBridge,
|
||||
shouldSuggestPasskeyBridge,
|
||||
} from '@app/features/auth/utils/PasskeyBridge';
|
||||
import * as WebAuthnUtils from '@app/features/auth/utils/WebAuthnUtils';
|
||||
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
import {useCallback, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const logger = Logger.create('useLoginFlow');
|
||||
@@ -57,6 +58,20 @@ export function useLoginCompletion(mode: LoginCompletionMode) {
|
||||
return {completeLogin};
|
||||
}
|
||||
|
||||
type LegacyPasskeyLoginOutcome = LoginSuccessPayload | 'cancelled' | 'navigating';
|
||||
|
||||
async function runLegacyPasskeyLogin(mfa: MfaChallenge | null): Promise<LegacyPasskeyLoginOutcome> {
|
||||
if (!Platform.isElectron) {
|
||||
await startPasskeyBridgePageLogin(mfa, `${window.location.pathname}${window.location.search}`);
|
||||
return 'navigating';
|
||||
}
|
||||
const result =
|
||||
mfa === null
|
||||
? await runPasskeyBridgeNativeLogin('login')
|
||||
: await runPasskeyBridgeNativeLogin('login_mfa', mfa.ticket);
|
||||
return result.status === 'completed' ? toLoginSuccessPayload(result) : 'cancelled';
|
||||
}
|
||||
|
||||
const handleLoginOutcome = async (
|
||||
result: LoginResult,
|
||||
onLoginSuccess?: (payload: LoginSuccessPayload) => Promise<void> | void,
|
||||
@@ -95,9 +110,7 @@ export function useLoginFormController({
|
||||
onRequireMfa,
|
||||
onRequireIpAuthorization,
|
||||
}: LoginFormControllerOptions) {
|
||||
const {i18n} = useLingui();
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const {form, isLoading, fieldErrors, error} = useAuthForm({
|
||||
initialValues: {email: '', password: ''},
|
||||
onSubmit: async (values) => {
|
||||
@@ -119,69 +132,63 @@ export function useLoginFormController({
|
||||
}
|
||||
});
|
||||
}, [onLoginSuccess, redirectPath]);
|
||||
const completePasskeyLogin = useCallback(
|
||||
async (options: PublicKeyCredentialRequestOptionsJSON, credential: AuthenticationResponseJSON) => {
|
||||
const response = await authenticateWithWebAuthn({
|
||||
response: credential,
|
||||
challenge: options.challenge,
|
||||
inviteCode,
|
||||
});
|
||||
await onLoginSuccess?.(response);
|
||||
const handlePasskeyLogin = useCallback(async () => {
|
||||
setIsPasskeyLoading(true);
|
||||
const migrationOrigin = isPasskeyMigrationOrigin();
|
||||
let navigating = false;
|
||||
try {
|
||||
let outcome: LegacyPasskeyLoginOutcome | null = null;
|
||||
if (!migrationOrigin || readPasskeyLoginRoute() === 'native') {
|
||||
await WebAuthnUtils.assertWebAuthnSupported();
|
||||
const options = await getWebAuthnAuthenticationOptions();
|
||||
const credential = await WebAuthnUtils.performAuthentication(options).catch((error: unknown) => {
|
||||
if (migrationOrigin && isPasskeyCeremonyDismissed(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (credential !== null) {
|
||||
outcome = await authenticateWithWebAuthn({
|
||||
response: credential,
|
||||
challenge: options.challenge,
|
||||
inviteCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (outcome === null) {
|
||||
writePasskeyLoginRoute('legacy');
|
||||
outcome = await runLegacyPasskeyLogin(null).catch((error: unknown) => {
|
||||
writePasskeyLoginRoute('native');
|
||||
throw error;
|
||||
});
|
||||
if (outcome === 'cancelled') {
|
||||
writePasskeyLoginRoute('native');
|
||||
}
|
||||
}
|
||||
navigating = outcome === 'navigating';
|
||||
if (typeof outcome === 'string') {
|
||||
return;
|
||||
}
|
||||
await onLoginSuccess?.(outcome);
|
||||
if (redirectPath) {
|
||||
RouterUtils.replaceWith(redirectPath);
|
||||
}
|
||||
},
|
||||
[inviteCode, onLoginSuccess, redirectPath],
|
||||
);
|
||||
const handlePasskeyLogin = useCallback(async () => {
|
||||
setIsPasskeyLoading(true);
|
||||
try {
|
||||
await WebAuthnUtils.assertWebAuthnSupported();
|
||||
const options = await getWebAuthnAuthenticationOptions();
|
||||
const credential = await WebAuthnUtils.performAuthentication(options);
|
||||
await completePasskeyLogin(options, credential);
|
||||
} catch (err) {
|
||||
if (err instanceof CaptchaCancelledError) {
|
||||
return;
|
||||
}
|
||||
logger.error('Passkey login failed', err);
|
||||
if (shouldSuggestPasskeyBridge(err)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
return;
|
||||
}
|
||||
if (err instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
|
||||
ToastCommands.error(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
|
||||
return;
|
||||
}
|
||||
const userCancelled =
|
||||
err instanceof DOMException && (err.name === 'NotAllowedError' || err.name === 'AbortError');
|
||||
if (isDesktop() && !userCancelled) {
|
||||
handleDesktopPasskeyHandoff();
|
||||
}
|
||||
} finally {
|
||||
setIsPasskeyLoading(false);
|
||||
}
|
||||
}, [completePasskeyLogin, handleDesktopPasskeyHandoff, i18n]);
|
||||
const handlePasskeyBridgeLogin = useCallback(() => {
|
||||
const options = getWebAuthnAuthenticationOptions();
|
||||
const credential = runPasskeyViaBridge('authenticate', options);
|
||||
setIsPasskeyLoading(true);
|
||||
Promise.all([options, credential])
|
||||
.then(([resolvedOptions, resolvedCredential]) => completePasskeyLogin(resolvedOptions, resolvedCredential))
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof CaptchaCancelledError) {
|
||||
return;
|
||||
}
|
||||
logger.error('Passkey login in the pop-up window failed', err);
|
||||
const descriptor = describePasskeyBridgeFailure(err);
|
||||
if (descriptor) {
|
||||
ToastCommands.error(i18n._(descriptor));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!navigating) {
|
||||
setIsPasskeyLoading(false);
|
||||
});
|
||||
}, [completePasskeyLogin, i18n]);
|
||||
}
|
||||
}
|
||||
}, [inviteCode, onLoginSuccess, redirectPath, handleDesktopPasskeyHandoff]);
|
||||
return {
|
||||
form,
|
||||
isLoading,
|
||||
@@ -189,8 +196,6 @@ export function useLoginFormController({
|
||||
error,
|
||||
handlePasskeyLogin,
|
||||
handlePasskeyBrowserLogin: handleDesktopPasskeyHandoff,
|
||||
handlePasskeyBridgeLogin,
|
||||
passkeyBridgeSuggested,
|
||||
isPasskeyLoading,
|
||||
};
|
||||
}
|
||||
@@ -207,9 +212,8 @@ interface MfaControllerOptions {
|
||||
}
|
||||
|
||||
export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}: MfaControllerOptions) {
|
||||
const {i18n} = useLingui();
|
||||
const [isWebAuthnLoading, setIsWebAuthnLoading] = useState(false);
|
||||
const [passkeyBridgeSuggested, setPasskeyBridgeSuggested] = useState(false);
|
||||
const preferLegacyRef = useRef(false);
|
||||
const {form, isLoading, fieldErrors} = useAuthForm({
|
||||
initialValues: {code: ''},
|
||||
onSubmit: async (values) => {
|
||||
@@ -227,54 +231,51 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
|
||||
firstFieldName: 'code',
|
||||
redirectPath: undefined,
|
||||
});
|
||||
const completeWebAuthnMfa = useCallback(
|
||||
async (options: PublicKeyCredentialRequestOptionsJSON, credential: AuthenticationResponseJSON) => {
|
||||
const response = await authenticateMfaWithWebAuthn({
|
||||
response: credential,
|
||||
challenge: options.challenge,
|
||||
ticket,
|
||||
inviteCode,
|
||||
});
|
||||
await onLoginSuccess?.(response);
|
||||
},
|
||||
[inviteCode, onLoginSuccess, ticket],
|
||||
);
|
||||
const handleWebAuthn = useCallback(async () => {
|
||||
setIsWebAuthnLoading(true);
|
||||
let navigating = false;
|
||||
try {
|
||||
const options = await getWebAuthnMfaOptions(ticket);
|
||||
const credential = await WebAuthnUtils.performAuthentication(options);
|
||||
await completeWebAuthnMfa(options, credential);
|
||||
const migrationOrigin = isPasskeyMigrationOrigin();
|
||||
const runsOnPage =
|
||||
!migrationOrigin || (!preferLegacyRef.current && (options.rpId === undefined || rpIdMatchesPage(options.rpId)));
|
||||
let response: LoginSuccessPayload;
|
||||
if (runsOnPage) {
|
||||
const credential = await WebAuthnUtils.performAuthentication(options).catch((error: unknown) => {
|
||||
if (migrationOrigin && isPasskeyCeremonyDismissed(error)) {
|
||||
preferLegacyRef.current = true;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
response = await authenticateMfaWithWebAuthn({
|
||||
response: credential,
|
||||
challenge: options.challenge,
|
||||
ticket,
|
||||
inviteCode,
|
||||
});
|
||||
} else {
|
||||
const outcome = await runLegacyPasskeyLogin({ticket, ...methods}).catch((error: unknown) => {
|
||||
preferLegacyRef.current = false;
|
||||
throw error;
|
||||
});
|
||||
navigating = outcome === 'navigating';
|
||||
if (outcome === 'cancelled') {
|
||||
preferLegacyRef.current = false;
|
||||
}
|
||||
if (typeof outcome === 'string') {
|
||||
return;
|
||||
}
|
||||
response = outcome;
|
||||
}
|
||||
await onLoginSuccess?.(response);
|
||||
} catch (error) {
|
||||
logger.error('WebAuthn MFA failed', error);
|
||||
if (shouldSuggestPasskeyBridge(error)) {
|
||||
setPasskeyBridgeSuggested(true);
|
||||
return;
|
||||
}
|
||||
if (error instanceof WebAuthnUtils.PasskeyDomainUnsupportedError) {
|
||||
ToastCommands.error(i18n._(WebAuthnUtils.PASSKEY_DOMAIN_UNSUPPORTED_DESCRIPTOR));
|
||||
}
|
||||
} finally {
|
||||
setIsWebAuthnLoading(false);
|
||||
}
|
||||
}, [completeWebAuthnMfa, ticket, i18n]);
|
||||
const handlePasskeyBridge = useCallback(() => {
|
||||
const options = getWebAuthnMfaOptions(ticket);
|
||||
const credential = runPasskeyViaBridge('authenticate', options);
|
||||
setIsWebAuthnLoading(true);
|
||||
Promise.all([options, credential])
|
||||
.then(([resolvedOptions, resolvedCredential]) => completeWebAuthnMfa(resolvedOptions, resolvedCredential))
|
||||
.catch((error: unknown) => {
|
||||
logger.error('WebAuthn MFA in the pop-up window failed', error);
|
||||
const descriptor = describePasskeyBridgeFailure(error);
|
||||
if (descriptor) {
|
||||
ToastCommands.error(i18n._(descriptor));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!navigating) {
|
||||
setIsWebAuthnLoading(false);
|
||||
});
|
||||
}, [completeWebAuthnMfa, ticket, i18n]);
|
||||
}
|
||||
}
|
||||
}, [inviteCode, methods, onLoginSuccess, ticket]);
|
||||
const supports = useMemo(
|
||||
() => ({totp: methods.totp, webauthn: methods.webauthn, backupCodes: methods.backupCodes}),
|
||||
[methods.totp, methods.webauthn, methods.backupCodes],
|
||||
@@ -284,8 +285,6 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
|
||||
isLoading,
|
||||
fieldErrors,
|
||||
handleWebAuthn,
|
||||
handlePasskeyBridge,
|
||||
passkeyBridgeSuggested,
|
||||
isWebAuthnLoading,
|
||||
supports,
|
||||
};
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/ErrorFallback.module.css';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {
|
||||
type PasskeyBridgeSession,
|
||||
type PasskeyBridgeViewState,
|
||||
startPasskeyBridgeSession,
|
||||
} from '@app/features/auth/passkey_bridge/PasskeyBridgeSession';
|
||||
import {CONTINUE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
const USE_YOUR_PASSKEY_DESCRIPTOR = msg({
|
||||
message: 'Use your passkey',
|
||||
comment: 'Heading of the small pop-up window that runs a passkey prompt for the new web address.',
|
||||
});
|
||||
const CONTINUE_TO_SIGN_IN_DESCRIPTOR = msg({
|
||||
message: 'Continue with your passkey to sign in to {host}',
|
||||
comment:
|
||||
'Body of the passkey pop-up window when signing in or confirming identity. host is a web address such as fluxer.com.',
|
||||
});
|
||||
const CONTINUE_TO_CREATE_DESCRIPTOR = msg({
|
||||
message: 'Continue to create a passkey for {host}',
|
||||
comment: 'Body of the passkey pop-up window when adding a new passkey. host is a web address such as fluxer.com.',
|
||||
});
|
||||
const CLOSE_WINDOW_DESCRIPTOR = msg({
|
||||
message: 'Close window',
|
||||
comment: 'Button in the passkey pop-up window that closes it after an error.',
|
||||
});
|
||||
const BRIDGE_UNAVAILABLE_DESCRIPTOR = msg({
|
||||
message: 'Open this window from {productName} to use your passkey.',
|
||||
comment: 'Error in the passkey pop-up window when someone opens its address directly. productName is the app name.',
|
||||
});
|
||||
const BRIDGE_REJECTED_DESCRIPTOR = msg({
|
||||
message: 'This passkey request could not be verified. Close this window and try again.',
|
||||
comment: 'Error in the passkey pop-up window when the request it received is not valid.',
|
||||
});
|
||||
const BRIDGE_TIMED_OUT_DESCRIPTOR = msg({
|
||||
message: 'This passkey request timed out. Close this window and try again.',
|
||||
comment: 'Error in the passkey pop-up window after five minutes without finishing.',
|
||||
});
|
||||
|
||||
interface PasskeyBridgeScreenProps {
|
||||
openerOrigin: string;
|
||||
}
|
||||
|
||||
export const PasskeyBridgeScreen: React.FC<PasskeyBridgeScreenProps> = ({openerOrigin}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [state, setState] = useState<PasskeyBridgeViewState>({status: 'waiting'});
|
||||
const sessionRef = useRef<PasskeyBridgeSession | null>(null);
|
||||
useEffect(() => {
|
||||
const session = startPasskeyBridgeSession(openerOrigin, setState);
|
||||
sessionRef.current = session;
|
||||
return () => {
|
||||
session.dispose();
|
||||
sessionRef.current = null;
|
||||
};
|
||||
}, [openerOrigin]);
|
||||
const handleContinue = useCallback(() => {
|
||||
sessionRef.current?.continueCeremony();
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
window.close();
|
||||
}, []);
|
||||
const host = new URL(openerOrigin).host;
|
||||
const failure =
|
||||
state.status === 'unavailable'
|
||||
? i18n._(BRIDGE_UNAVAILABLE_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
: state.status === 'rejected'
|
||||
? i18n._(BRIDGE_REJECTED_DESCRIPTOR)
|
||||
: state.status === 'timed_out'
|
||||
? i18n._(BRIDGE_TIMED_OUT_DESCRIPTOR)
|
||||
: null;
|
||||
const kind = state.status === 'ready' || state.status === 'running' ? state.kind : 'authenticate';
|
||||
return (
|
||||
<main className={styles.errorFallbackContainer} data-flx="auth.passkey-bridge-screen.container">
|
||||
<FluxerIcon className={styles.errorFallbackIcon} data-flx="auth.passkey-bridge-screen.icon" />
|
||||
<div className={styles.errorFallbackContent} data-flx="auth.passkey-bridge-screen.content">
|
||||
<h1 className={styles.errorFallbackTitle} data-flx="auth.passkey-bridge-screen.title">
|
||||
{i18n._(USE_YOUR_PASSKEY_DESCRIPTOR)}
|
||||
</h1>
|
||||
<p
|
||||
className={styles.errorFallbackDescription}
|
||||
role={failure === null ? undefined : 'alert'}
|
||||
data-flx="auth.passkey-bridge-screen.description"
|
||||
>
|
||||
{failure ??
|
||||
i18n._(kind === 'register' ? CONTINUE_TO_CREATE_DESCRIPTOR : CONTINUE_TO_SIGN_IN_DESCRIPTOR, {host})}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.errorFallbackActions} data-flx="auth.passkey-bridge-screen.actions">
|
||||
{failure === null ? (
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
disabled={state.status !== 'ready'}
|
||||
submitting={state.status === 'waiting' || state.status === 'running'}
|
||||
autoFocus
|
||||
data-flx="auth.passkey-bridge-screen.button.continue"
|
||||
>
|
||||
{i18n._(CONTINUE_DESCRIPTOR)}
|
||||
</Button>
|
||||
) : state.status !== 'unavailable' ? (
|
||||
<Button variant="secondary" onClick={handleClose} data-flx="auth.passkey-bridge-screen.button.close">
|
||||
{i18n._(CLOSE_WINDOW_DESCRIPTOR)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user