mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #4033 from vitorpamplona/claude/relay-auth-always-allow-09cwpc
Add account-wide relay auth policy choices to NIP-42 prompt
This commit is contained in:
@@ -3662,7 +3662,7 @@ class Account(
|
||||
|
||||
// Blocking a relay has to forget any "just for now" login to it, or unblocking later would
|
||||
// silently resume authenticating off an answer given before the block. Blocking is the
|
||||
// strongest signal available here — the weaker "never allow" already drops the grant via
|
||||
// strongest signal available here — the weaker per-relay "never" answer already drops the grant via
|
||||
// RelayAuthPermissionLedger.setDecision, so it would be odd for the stronger one not to.
|
||||
//
|
||||
// Observed rather than hooked onto the local block action because the kind-10006 list is
|
||||
|
||||
+131
-24
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPrompt
|
||||
@@ -74,8 +75,6 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.UserAuth
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -95,15 +94,14 @@ private const val AVATAR_SLOT = "avatar"
|
||||
/**
|
||||
* App-wide host for NIP-42 auth prompts. Collects [RelayAuthPromptBus.prompts] and shows one
|
||||
* dialog at a time explaining *why* a relay wants the user to log in (who it serves), letting the
|
||||
* user allow once, always allow, or block the relay. Dismissing answers [UserAuthChoice.DISMISS],
|
||||
* user answer for this relay (once or for good, either way) or for every relay at once. Dismissing
|
||||
* answers [UserAuthChoice.DISMISS],
|
||||
* which the bus also falls back to on timeout, so a relay connection never blocks on the UI.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayAuthPromptHost(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val bus = remember { Amethyst.instance.authCoordinator.promptBus }
|
||||
fun RelayAuthPromptHost(accountViewModel: AccountViewModel) {
|
||||
val coordinator = remember { Amethyst.instance.authCoordinator }
|
||||
val bus = remember { coordinator.promptBus }
|
||||
val queue = remember { mutableStateListOf<RelayAuthPrompt>() }
|
||||
|
||||
LaunchedEffect(bus) {
|
||||
@@ -119,7 +117,29 @@ fun RelayAuthPromptHost(
|
||||
// its answer window starts from the moment it is visible rather than from the challenge.
|
||||
queue.firstOrNull { !it.isResolved }?.let { prompt ->
|
||||
LaunchedEffect(prompt) { prompt.markShown() }
|
||||
RelayAuthPromptDialog(prompt, accountViewModel, nav) { choice ->
|
||||
RelayAuthPromptDialog(prompt, accountViewModel) { choice ->
|
||||
choice.policyEverywhere?.let { policy ->
|
||||
// Applied here, not left to the answer below, so the setting survives an expired
|
||||
// prompt — the answer window runs while the user reads the confirmation. See
|
||||
// AuthCoordinator.applyPolicyEverywhere.
|
||||
coordinator.applyPolicyEverywhere(prompt.askingAccount, policy)
|
||||
|
||||
// "all relays" has to mean the ones already queued behind this dialog too. They were
|
||||
// decided before the policy existed, so nothing else resolves them, and asking again
|
||||
// about relay B right after being told "always/never, all relays" reads as the answer
|
||||
// not having taken. Same account only: the policy is that account's.
|
||||
//
|
||||
// markShown() first even though these are never shown: a prompt still waiting its
|
||||
// turn is parked in the bus's queue-wait window, and an answer dropped into it does
|
||||
// not land until that window ends — five minutes of an unauthenticated relay the
|
||||
// user already answered for. Marking it shown opens its answer window immediately.
|
||||
queue.toList().forEach {
|
||||
if (it.askingAccount == prompt.askingAccount) {
|
||||
it.markShown()
|
||||
it.respond(choice)
|
||||
}
|
||||
}
|
||||
}
|
||||
prompt.respond(choice)
|
||||
queue.remove(prompt)
|
||||
}
|
||||
@@ -136,18 +156,40 @@ fun RelayAuthPromptHost(
|
||||
* separate blocks: a purpose-specific title, a purpose label, an avatar row, and a red "if you don't"
|
||||
* consequence line, each of which repeated the same name.
|
||||
*
|
||||
* Two buttons and a switch replace four stacked buttons. Nothing here writes a global setting: the
|
||||
* old "always deliver my messages" silently flipped the policy to CUSTOM plus two account-wide
|
||||
* toggles, so it is now a link to the screen where those toggles are visible.
|
||||
* Two buttons and a switch replace four stacked buttons, and the switch means what it says for
|
||||
* *both* of them: it is the answer's scope, not a modifier on "Log in". The four combinations are
|
||||
* the four [UserAuthChoice] values for this relay — log in once or always, refuse once or for good —
|
||||
* which is why there is no separate "Never allow" button any more: it was "Not now" with the switch
|
||||
* on, written twice. Flipping the switch relabels the buttons to the answer they now give
|
||||
* ("Always log in" / "Never"), so the standing answer is never given under a one-off label.
|
||||
*
|
||||
* That frees the row underneath for the two answers this relay's buttons cannot give, one per
|
||||
* direction: "Always, all relays" and "Never, all relays" set the account's [RelayAuthPolicy] so
|
||||
* nothing is asked again, either way. Both are account-wide, so both confirm before they write —
|
||||
* see [PolicyEverywhereConfirmation]. The old "always deliver my messages" is still gone: it
|
||||
* flipped the policy to CUSTOM plus two account-wide toggles *silently*, which is the part that was
|
||||
* wrong, not the writing itself.
|
||||
*/
|
||||
@Composable
|
||||
private fun RelayAuthPromptDialog(
|
||||
prompt: RelayAuthPrompt,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onChoice: (UserAuthChoice) -> Unit,
|
||||
) {
|
||||
var rememberRelay by remember(prompt) { mutableStateOf(false) }
|
||||
// The account-wide answer waiting on its confirmation, or null while the prompt itself is up.
|
||||
var confirming by remember(prompt) { mutableStateOf<UserAuthChoice?>(null) }
|
||||
val accountName = rememberDisplayName(prompt.askingAccount, accountViewModel)
|
||||
|
||||
confirming?.let { choice ->
|
||||
PolicyEverywhereConfirmation(
|
||||
choice = choice,
|
||||
accountName = accountName,
|
||||
onDismiss = { confirming = null },
|
||||
onConfirm = { onChoice(choice) },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// The purpose the user is most likely to recognize as "what I was just doing".
|
||||
val primary = remember(prompt) { prompt.purposes.primary() }
|
||||
@@ -189,7 +231,7 @@ private fun RelayAuthPromptDialog(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
RelayHeader(prompt, accountViewModel)
|
||||
Text(
|
||||
text = stringRes(R.string.relay_auth_login_as, rememberDisplayName(prompt.askingAccount, accountViewModel)),
|
||||
text = stringRes(R.string.relay_auth_login_as, accountName),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
}
|
||||
@@ -224,35 +266,100 @@ private fun RelayAuthPromptDialog(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Both buttons read the switch, which is what makes it the scope of the answer
|
||||
// rather than a modifier on one of them. Refusing *and* remembering is the DENY
|
||||
// the red "Never allow" button used to write on its own.
|
||||
//
|
||||
// And both say so: with the switch on they relabel to the standing answer they
|
||||
// now give, so nothing turns a one-off refusal into a permanent one behind a
|
||||
// label that still reads "Not now". The refusal takes the error colour with it,
|
||||
// which is the weight the removed red button carried.
|
||||
OutlinedButton(
|
||||
onClick = { onChoice(UserAuthChoice.DISMISS) },
|
||||
onClick = { onChoice(if (rememberRelay) UserAuthChoice.BLOCK else UserAuthChoice.DISMISS) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text(stringRes(R.string.relay_auth_not_now)) }
|
||||
colors =
|
||||
if (rememberRelay) {
|
||||
ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
} else {
|
||||
ButtonDefaults.outlinedButtonColors()
|
||||
},
|
||||
) { Text(stringRes(if (rememberRelay) R.string.relay_auth_never else R.string.relay_auth_not_now)) }
|
||||
Button(
|
||||
onClick = { onChoice(if (rememberRelay) UserAuthChoice.ALWAYS_ALLOW else UserAuthChoice.ALLOW_ONCE) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text(stringRes(R.string.relay_auth_log_in)) }
|
||||
) { Text(stringRes(if (rememberRelay) R.string.relay_auth_always_log_in else R.string.relay_auth_log_in)) }
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { onChoice(UserAuthChoice.BLOCK) },
|
||||
onClick = { confirming = UserAuthChoice.NEVER_ALLOW_EVERYWHERE },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) { Text(stringRes(R.string.relay_auth_never_allow), style = MaterialTheme.typography.labelMedium) }
|
||||
) { Text(stringRes(R.string.relay_auth_never_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
|
||||
TextButton(
|
||||
onClick = {
|
||||
onChoice(UserAuthChoice.DISMISS)
|
||||
nav.nav(Route.RelayAuthSettings)
|
||||
},
|
||||
) { Text(stringRes(R.string.relay_auth_how_we_decide), style = MaterialTheme.typography.labelMedium) }
|
||||
onClick = { confirming = UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE },
|
||||
) { Text(stringRes(R.string.relay_auth_always_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The two actions in this flow that write an account-wide setting, so they ask first.
|
||||
*
|
||||
* The buttons above are about the one relay in the title, and both of their outcomes are listed and
|
||||
* reversible on the settings screen. These two are not about this relay at all: they decide every
|
||||
* relay that ever asks — revealing the npub named above to all of them, or cutting it off from all of
|
||||
* them — and a link label cannot carry that. The confirmation is where the scope becomes visible,
|
||||
* and it names the consequence each direction actually has.
|
||||
*/
|
||||
@Composable
|
||||
private fun PolicyEverywhereConfirmation(
|
||||
choice: UserAuthChoice,
|
||||
accountName: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
val always = choice == UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(stringRes(if (always) R.string.relay_auth_always_everywhere_title else R.string.relay_auth_never_everywhere_title))
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
stringRes(
|
||||
if (always) R.string.relay_auth_always_everywhere_body else R.string.relay_auth_never_everywhere_body,
|
||||
accountName,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
// The label echoes the link that opened this, not the buttons behind it: with the switch
|
||||
// on those now read "Always log in" / "Never" for *this relay*, so confirming an
|
||||
// account-wide answer under the same words would make the scope ambiguous exactly where
|
||||
// it matters most.
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
colors =
|
||||
if (always) {
|
||||
ButtonDefaults.buttonColors()
|
||||
} else {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(stringRes(if (always) R.string.relay_auth_always_allow_everywhere else R.string.relay_auth_never_allow_everywhere))
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** The relay leads the dialog, because the relay is the thing being trusted. */
|
||||
@Composable
|
||||
private fun RelayHeader(
|
||||
|
||||
+39
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -155,10 +156,22 @@ class AuthCoordinator(
|
||||
account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.ALLOW)
|
||||
true
|
||||
}
|
||||
UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE -> {
|
||||
// No per-relay decision is stored: the policy already answers this
|
||||
// relay, and an exception on top of it would survive a later switch
|
||||
// back to "decide per relay". The UI has normally applied this
|
||||
// already (see [applyPolicyEverywhere]); repeating it is free.
|
||||
applyPolicyEverywhere(account.pubKey, RelayAuthPolicy.ALWAYS)
|
||||
true
|
||||
}
|
||||
UserAuthChoice.BLOCK -> {
|
||||
account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.DENY)
|
||||
false
|
||||
}
|
||||
UserAuthChoice.NEVER_ALLOW_EVERYWHERE -> {
|
||||
applyPolicyEverywhere(account.pubKey, RelayAuthPolicy.NEVER)
|
||||
false
|
||||
}
|
||||
UserAuthChoice.DISMISS -> false
|
||||
}
|
||||
}
|
||||
@@ -179,6 +192,32 @@ class AuthCoordinator(
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Sets [askingAccount]'s top-level NIP-42 policy — the "Always, all relays" / "Never, all relays"
|
||||
* answers. Public because the *prompt* calls it the moment the user confirms, instead of relying
|
||||
* on the answer reaching the suspended challenge above: that answer window is 60s from the dialog
|
||||
* appearing (see [RelayAuthPromptBus]), and a user who spends it reading the confirmation would
|
||||
* otherwise have their setting silently dropped along with the expired prompt. The relay's own
|
||||
* AUTH is the only thing worth losing to a timeout; a setting is not, and the next challenge —
|
||||
* seconds later, on reconnect — is answered by the policy this wrote.
|
||||
*
|
||||
* Goes through [Account.changeDefaultRelayAuthPolicy] rather than the settings object because
|
||||
* that is what pairs [RelayAuthPolicy.NEVER] with dropping this run's session grants, which
|
||||
* outrank the policy and would otherwise keep authenticating the relays just answered "log in".
|
||||
*
|
||||
* Takes a pubkey rather than an [Account] because the prompt names the account whose npub is at
|
||||
* stake, which on a multi-account device is not the one the screen is showing. Unknown pubkeys
|
||||
* (an account logged out while its prompt was up) are a no-op.
|
||||
*/
|
||||
fun applyPolicyEverywhere(
|
||||
askingAccount: HexKey,
|
||||
policy: RelayAuthPolicy,
|
||||
) {
|
||||
authWithAccounts.distinctValues().forEach { screen ->
|
||||
if (screen.account.pubKey == askingAccount) screen.account.changeDefaultRelayAuthPolicy(policy)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The joined Concord community whose plane [planeAddress] is, across every watched account, or
|
||||
* null when the pubkey isn't a plane of ours. Feeds [RelayAuthPurposeDeriver] so a pending plane
|
||||
|
||||
+3
-2
@@ -182,7 +182,7 @@ class RelayAuthPermissionLedger(
|
||||
*
|
||||
* Blocking already denies while it is in force, so this is about what happens *after* it is
|
||||
* lifted: without it, unblocking would resume authenticating off an answer given before the
|
||||
* block. The weaker "never allow" drops the grant too (see [setDecision]), so the stronger
|
||||
* block. The weaker "not now, and remember it" drops the grant too (see [setDecision]), so the stronger
|
||||
* signal has to as well.
|
||||
*/
|
||||
fun revokeSessionGrantsFor(blockedRelayUrls: Collection<String>) = blockedRelayUrls.forEach(sessionGrants::revoke)
|
||||
@@ -198,7 +198,8 @@ class RelayAuthPermissionLedger(
|
||||
* *after* its disk write returns — so a challenge arriving between them must never see neither.
|
||||
* Which side to fail on depends on the decision:
|
||||
* - **DENY** revokes first. The window then asks or denies, never signs: a user who just pressed
|
||||
* "never allow" must not get one more AUTH out of the grant they are replacing.
|
||||
* "not now" with the remember switch on must not get one more AUTH out of the grant they are
|
||||
* replacing.
|
||||
* - **ALLOW** revokes last, so the grant still covers the window. Revoking first left the relay
|
||||
* momentarily undecided, which re-prompted the user who had just pressed "always" — the very
|
||||
* dialog this whole feature exists to stop.
|
||||
|
||||
+32
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -40,11 +41,42 @@ enum class UserAuthChoice {
|
||||
/** Authenticate now and remember ALLOW for this relay. */
|
||||
ALWAYS_ALLOW,
|
||||
|
||||
/**
|
||||
* Authenticate now and switch the asking account's top-level policy to
|
||||
* [RelayAuthPolicy.ALWAYS], so every relay that
|
||||
* asks is answered without a prompt. The account-wide counterpart of [ALWAYS_ALLOW]; the dialog
|
||||
* confirms it before sending it, because it is a global setting.
|
||||
*/
|
||||
ALWAYS_ALLOW_EVERYWHERE,
|
||||
|
||||
/** Do not authenticate and remember DENY for this relay. */
|
||||
BLOCK,
|
||||
|
||||
/**
|
||||
* Do not authenticate, and switch the asking account's top-level policy to
|
||||
* [RelayAuthPolicy.NEVER], so no relay is ever
|
||||
* answered again. The account-wide counterpart of [BLOCK], confirmed the same way — and, like
|
||||
* every route through [com.vitorpamplona.amethyst.model.Account.changeDefaultRelayAuthPolicy],
|
||||
* it drops this run's session grants, which would otherwise outrank the policy it just set.
|
||||
*/
|
||||
NEVER_ALLOW_EVERYWHERE,
|
||||
|
||||
/** No decision (dismissed or timed out) — do not authenticate, don't remember. */
|
||||
DISMISS,
|
||||
;
|
||||
|
||||
/**
|
||||
* The account-wide policy this choice sets, or null for the four answers that are only about the
|
||||
* relay being asked about. Lets a caller apply the setting without re-deriving which choices are
|
||||
* account-wide.
|
||||
*/
|
||||
val policyEverywhere: RelayAuthPolicy?
|
||||
get() =
|
||||
when (this) {
|
||||
ALWAYS_ALLOW_EVERYWHERE -> RelayAuthPolicy.ALWAYS
|
||||
NEVER_ALLOW_EVERYWHERE -> RelayAuthPolicy.NEVER
|
||||
ALLOW_ONCE, ALWAYS_ALLOW, BLOCK, DISMISS -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -335,9 +335,9 @@ fun AppNavigation(
|
||||
val nav = rememberNav()
|
||||
|
||||
// Shows the "log in to this relay?" dialog when a NIP-42 challenge needs the user to decide.
|
||||
// Hosted here rather than in LoggedInPage because the dialog links out to the relay-login
|
||||
// settings screen, which needs the nav created just above.
|
||||
RelayAuthPromptHost(accountViewModel, nav)
|
||||
// Hosted here rather than in LoggedInPage so one dialog serves the whole shell: challenges
|
||||
// arrive off the shared relay socket, not from whatever screen happens to be on top.
|
||||
RelayAuthPromptHost(accountViewModel)
|
||||
|
||||
// One layout decision per window size for the whole shell: bottom bar vs rail vs
|
||||
// permanent drawer, plus the docked notification panel. Every screen, bar and panel
|
||||
|
||||
@@ -1185,6 +1185,16 @@
|
||||
<string name="relay_auth_remember_relay">Remember for this relay</string>
|
||||
<string name="relay_auth_log_in">Log in</string>
|
||||
<string name="relay_auth_not_now">Not now</string>
|
||||
<!-- What the same two buttons say once "Remember for this relay" is on. -->
|
||||
<string name="relay_auth_always_log_in">Always log in</string>
|
||||
<string name="relay_auth_never">Never</string>
|
||||
<string name="relay_auth_always_allow_everywhere">Always, all relays</string>
|
||||
<string name="relay_auth_never_allow_everywhere">Never, all relays</string>
|
||||
<string name="relay_auth_always_everywhere_title">Log in to every relay?</string>
|
||||
<string name="relay_auth_always_everywhere_body">Amethyst will log in as %1$s to every relay that asks, and stop asking. Relays you blocked, and the ones you set to never log in, stay that way. You can change this under Relay login.</string>
|
||||
<string name="relay_auth_never_everywhere_title">Never log in to any relay?</string>
|
||||
<string name="relay_auth_never_everywhere_body">Amethyst will stop telling relays that you are %1$s, and stop asking. Some relays will refuse to serve you: messages, replies and notifications may not go through. Relays you set to always log in stay that way. You can change this under Relay login.</string>
|
||||
<!-- No longer shown in the prompt: kept so the existing translations aren't orphaned. -->
|
||||
<string name="relay_auth_never_allow">Never allow</string>
|
||||
<string name="relay_auth_how_we_decide">How Amethyst decides</string>
|
||||
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The prompt's two account-wide links — "Always, all relays"
|
||||
* ([UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE]) and "Never, all relays"
|
||||
* ([UserAuthChoice.NEVER_ALLOW_EVERYWHERE]) — each do exactly one thing: flip the account's policy.
|
||||
* These tests pin what a flip has to buy — the prompt never comes back for *any* relay, in either
|
||||
* direction — and what it must not quietly do: write a per-relay exception that would outlive a
|
||||
* later switch back to "decide per relay", or override the rules that rank above the policy.
|
||||
*/
|
||||
class RelayAuthPolicyEverywhereTest {
|
||||
private val relay = "wss://auth.example.com/"
|
||||
private val other = "wss://elsewhere.example.com/"
|
||||
private val blockedRelay = "wss://blocked.example.com/"
|
||||
|
||||
private var policy = RelayAuthPolicy.CUSTOM
|
||||
private val store: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore()
|
||||
|
||||
private val ledger =
|
||||
RelayAuthPermissionLedger(
|
||||
store = store,
|
||||
globalPolicy = { policy },
|
||||
sessionGrants = RelayAuthSessionGrants(),
|
||||
isBlocked = { it == blockedRelay },
|
||||
)
|
||||
|
||||
/** A challenge we can explain but have no automatic rule for: the ASK case the prompt is shown for. */
|
||||
private fun askable(relayUrl: String) = RelayAuthContext(relayUrl, listOf(AuthPurpose(AuthPurposeKind.MY_INBOX)))
|
||||
|
||||
@Test
|
||||
fun theFlipAnswersThisRelayAndEveryOtherOne() =
|
||||
runTest {
|
||||
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay)))
|
||||
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(other)))
|
||||
|
||||
policy = RelayAuthPolicy.ALWAYS
|
||||
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(other)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay that happened to be asking gets no exception of its own. An ALLOW written here would
|
||||
* survive a later switch back to CUSTOM/NEVER and keep authenticating a relay the user thought
|
||||
* they had stopped — the policy is the whole answer, so it is the only thing that changes.
|
||||
*/
|
||||
@Test
|
||||
fun theFlipWritesNoPerRelayException() =
|
||||
runTest {
|
||||
policy = RelayAuthPolicy.ALWAYS
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
|
||||
|
||||
assertNull(store.loadDecision(relay))
|
||||
assertEquals(emptyMap<String, RelayAuthDecision>(), store.allDecisions())
|
||||
|
||||
policy = RelayAuthPolicy.CUSTOM
|
||||
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay)))
|
||||
}
|
||||
|
||||
/** Both rules that outrank the policy keep outranking it, which is what the confirmation promises. */
|
||||
@Test
|
||||
fun blockedRelaysAndNeverExceptionsStillWin() =
|
||||
runTest {
|
||||
ledger.setDecision(other, RelayAuthDecision.DENY)
|
||||
policy = RelayAuthPolicy.ALWAYS
|
||||
|
||||
assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(blockedRelay)))
|
||||
assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(other)))
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theNeverFlipSilencesThisRelayAndEveryOtherOne() =
|
||||
runTest {
|
||||
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay)))
|
||||
|
||||
policy = RelayAuthPolicy.NEVER
|
||||
|
||||
assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay)))
|
||||
assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(other)))
|
||||
assertNull(store.loadDecision(relay))
|
||||
}
|
||||
|
||||
/** The "Always, set by you" exceptions the settings screen lists are standing answers, not casual ones. */
|
||||
@Test
|
||||
fun theNeverFlipLeavesAlwaysExceptionsAlone() =
|
||||
runTest {
|
||||
ledger.setDecision(other, RelayAuthDecision.ALLOW)
|
||||
policy = RelayAuthPolicy.NEVER
|
||||
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(other)))
|
||||
assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The two account-wide answers are the only ones the host may apply to prompts other than the one
|
||||
* on screen, and only to the account that was asked. Pinning the mapping keeps that fan-out — and
|
||||
* the setting write that survives an expired prompt — from ever reaching a per-relay answer.
|
||||
*/
|
||||
@Test
|
||||
fun onlyTheAccountWideAnswersCarryAPolicy() {
|
||||
assertEquals(RelayAuthPolicy.ALWAYS, UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE.policyEverywhere)
|
||||
assertEquals(RelayAuthPolicy.NEVER, UserAuthChoice.NEVER_ALLOW_EVERYWHERE.policyEverywhere)
|
||||
assertTrue(
|
||||
listOf(
|
||||
UserAuthChoice.ALLOW_ONCE,
|
||||
UserAuthChoice.ALWAYS_ALLOW,
|
||||
UserAuthChoice.BLOCK,
|
||||
UserAuthChoice.DISMISS,
|
||||
).all { it.policyEverywhere == null },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the coordinator routes this through [com.vitorpamplona.amethyst.model.Account], which drops
|
||||
* the session grants with the flip: a grant left behind outranks the policy, so "never log in"
|
||||
* would keep authenticating exactly the relays the user had just answered "log in" for.
|
||||
*/
|
||||
@Test
|
||||
fun aSessionGrantWouldOutrankTheNeverFlipIfItSurvived() =
|
||||
runTest {
|
||||
val grants = RelayAuthSessionGrants()
|
||||
val ledger =
|
||||
RelayAuthPermissionLedger(
|
||||
store = InMemoryRelayAuthPermissionStore(),
|
||||
globalPolicy = { policy },
|
||||
sessionGrants = grants,
|
||||
)
|
||||
grants.grant(relay)
|
||||
policy = RelayAuthPolicy.NEVER
|
||||
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
|
||||
|
||||
grants.clear()
|
||||
|
||||
assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay)))
|
||||
}
|
||||
}
|
||||
+34
@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RelayAuthPromptBusTest {
|
||||
@@ -143,6 +144,39 @@ class RelayAuthPromptBusTest {
|
||||
assertEquals(UserAuthChoice.ALWAYS_ALLOW, callerB.await())
|
||||
}
|
||||
|
||||
/**
|
||||
* "Always/Never, all relays" is answered on one dialog and applies to the prompts still queued
|
||||
* behind it, which the host resolves without ever showing them. A prompt that is answered but not
|
||||
* marked shown sits in the queue-wait window — up to five minutes — before its caller reads the
|
||||
* answer already sitting in the deferred, so the relay it belongs to goes unauthenticated for that
|
||||
* long despite the user having answered. Marking it shown is what makes the answer land now.
|
||||
*/
|
||||
@Test
|
||||
fun anAnswerFannedOutToAQueuedPromptLandsWithoutWaitingOutTheQueueWindow() =
|
||||
runTest {
|
||||
val clock = testScheduler
|
||||
val bus = RelayAuthPromptBus(timeoutMs = 1_000L, queueWaitMs = 300_000L)
|
||||
val relayA = NormalizedRelayUrl("wss://a.relay.test")
|
||||
val relayB = NormalizedRelayUrl("wss://b.relay.test")
|
||||
|
||||
val surfaced = async { bus.prompts.take(2).toList() }
|
||||
val callerA = async { bus.requestDecision(relayA, emptyList(), alice, isMyOwnRelay = false) }
|
||||
val callerB = async { bus.requestDecision(relayB, emptyList(), alice, isMyOwnRelay = false) }
|
||||
val prompts = surfaced.await()
|
||||
|
||||
// Only A is on screen. The user's account-wide answer resolves B too, sight unseen.
|
||||
prompts[0].markShown()
|
||||
val start = clock.currentTime
|
||||
prompts.forEach {
|
||||
it.markShown()
|
||||
it.respond(UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE)
|
||||
}
|
||||
|
||||
assertEquals(UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE, callerA.await())
|
||||
assertEquals(UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE, callerB.await())
|
||||
assertTrue("the queued relay waited ${clock.currentTime - start}ms for an answer it already had", clock.currentTime - start < 1_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPromptNoHostCanEverShowStillTimesOut() =
|
||||
runTest {
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ data class RelayAuthInputs(
|
||||
* 1. Blocked-relay list → [RelayAuthVerdict.DENY] (never reveal identity to a blocked relay).
|
||||
* 2. Explicit per-relay override → honor it.
|
||||
* 3. [RelayAuthInputs.hasSessionGrant] → [RelayAuthVerdict.ALLOW]. Ranked *below* the stored override
|
||||
* so a later "never allow" — the only way a DENY can be written for a relay already granted this
|
||||
* so a later "never" answer — the only way a DENY can be written for a relay already granted this
|
||||
* session — takes effect immediately instead of losing to the in-memory grant.
|
||||
* 4. Top-level [RelayAuthPolicy]:
|
||||
* - [RelayAuthPolicy.NEVER] → DENY
|
||||
|
||||
Reference in New Issue
Block a user