refactor(quartz/relay): move auth identity from policy into connection scope

Authentication state (who is logged in on a connection) is connection scope,
not a policy decision. It was stored on FullAuthPolicy, which forced the
AuthScopedPolicy marker, the PolicyStack union, and a downcast in
RequestContext just to route it back out as scope.

Now the engine owns it: RelaySession holds the authenticatedUsers set behind
the (now public) requestContext; the data plane and policies read it through
RequestContext. The policy stays pure decision —
- onConnect(scope, send): a per-connection policy captures the read-only scope
  to gate on; shared singletons ignore it.
- onAuthenticated(): Boolean: the policy's vote on whether to record the
  verified pubkey (default false, so blind-accept policies never record an
  unverified identity). FullAuthPolicy runs authorize() then votes true.
- RelaySession.handleAuth performs the single, engine-side commit after the
  whole chain approves and a verifying policy votes to record.

FullAuthPolicy keeps all auth logic (challenge, accept(AuthCmd), gating,
authorize) and gains a protected authenticatedUsers accessor over the scope for
subclasses (restricted content / filter rewrite). Deletes AuthScopedPolicy,
PolicyStack.authenticatedUsers, and the RequestContext downcast.
This commit is contained in:
Claude
2026-06-04 16:45:03 +00:00
parent 6943c1a056
commit a54d9c92a7
10 changed files with 137 additions and 116 deletions
+4 -2
View File
@@ -151,8 +151,10 @@ non-storage relay: a single shared `EventSource` instance can serve
caller-relative results (trust/relevance scored from the viewer's perspective,
"for-you" feeds), restricted content (a pubkey's DMs returned only to that
pubkey), or per-connection tenancy — without smuggling auth state through a
side channel. `ctx.authenticatedUsers` reads live from the connection's policy,
so a REQ that arrives after the AUTH sees the freshly authenticated pubkey(s).
side channel. `ctx.authenticatedUsers` is a live view of the engine-owned
connection scope, so a REQ that arrives after the AUTH sees the freshly
recorded pubkey(s). (The engine records them on a successful NIP-42 AUTH; the
policy reads the same scope to gate.)
For per-connection state richer than the pubkey (e.g. a backend session token
minted in `FullAuthPolicy.authorize`), downcast `ctx.policy` to your own policy
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.server
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
@@ -76,15 +77,24 @@ class RelaySession(
private val subscriptions = LargeCache<String, Job>()
/**
* The per-connection context handed to the [store] on every REQ/COUNT so a
* source can see who is asking. [RequestContext.authenticatedUsers] reads
* live from [policy], so a REQ that arrives after a NIP-42 AUTH sees the
* freshly authenticated pubkey(s).
* The authenticated-identity store for this connection. The engine is the
* only writer (committed in [handleAuth] on a successful NIP-42 AUTH); the
* policy and the data plane read it through [requestContext].
*/
private val requestContext =
private val authenticatedUsers = mutableSetOf<HexKey>()
/**
* The per-connection scope. Handed to the [policy] at connect (so gating
* policies can read the authenticated users) and to the [store] on every
* REQ/COUNT (so a source can see who is asking). [RequestContext.authenticatedUsers]
* is a live view of [authenticatedUsers], so a REQ after a NIP-42 AUTH sees
* the freshly recorded pubkey(s).
*/
val requestContext: RequestContext =
object : RequestContext {
override val connectionId = id
override val policy = this@RelaySession.policy
override val authenticatedUsers: Set<HexKey> get() = this@RelaySession.authenticatedUsers
}
/** NIP-77 negentropy state for this connection. */
@@ -223,16 +233,21 @@ class RelaySession(
// The whole policy chain validated the AUTH. onAuthenticated runs any
// post-verification I/O (e.g. exchanging the verified event for a
// backend token) AND is where a policy commits the authentication, so a
// throw here cleanly fails the login — nothing was committed to undo.
try {
policy.onAuthenticated(cmd.event.pubKey, cmd.event)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
send(OkMessage.rejected(cmd.event.id, MachineReadablePrefix.ERROR, e.message ?: "authentication failed"))
return
}
// backend token) and votes on whether to record the identity. A throw
// here cleanly fails the login — the engine records nothing.
val record =
try {
policy.onAuthenticated(cmd.event.pubKey, cmd.event)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
send(OkMessage.rejected(cmd.event.id, MachineReadablePrefix.ERROR, e.message ?: "authentication failed"))
return
}
// Single, engine-side commit into the connection scope — after the full
// chain approved and a verifying policy voted to record.
if (record) authenticatedUsers.add(cmd.event.pubKey)
send(OkMessage(cmd.event.id, true, ""))
}
@@ -299,7 +314,7 @@ class RelaySession(
}
init {
policy.onConnect(::send)
policy.onConnect(requestContext, ::send)
}
companion object {
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.nip01Core.relay.server.backend
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.AuthScopedPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
/**
@@ -30,11 +29,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
* shared source can tailor its answer to the caller without smuggling state in
* through a side channel.
*
* This is what makes NIP-42 useful on a non-storage relay: the engine already
* knows the authenticated pubkey(s) (the connection's [IRelayPolicy] records
* them), and [RequestContext] is the path that carries them to the code that
* actually produces the events. With it, the same `EventSource` instance can
* serve:
* This is the connection scope: the engine owns it (one per
* [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession]) and records
* the authenticated pubkey(s) into it on a successful NIP-42 AUTH. That is what
* makes NIP-42 useful on a non-storage relay — [RequestContext] is the path
* that carries the caller's identity to the code that produces the events. With
* it, the same `EventSource` instance can serve:
*
* - **caller-relative results** — trust/relevance scored from
* [authenticatedUsers]'s perspective ("for-you" feeds, follow-aware search);
@@ -60,10 +60,10 @@ interface RequestContext {
val policy: IRelayPolicy
/**
* The pubkeys that have authenticated on this connection via NIP-42, read
* live from [policy]. Empty when the connection is unauthenticated or the
* policy does not track auth (i.e. is not an [AuthScopedPolicy]). This
* accessor encapsulates that downcast so a source needn't repeat it.
* The pubkeys that have authenticated on this connection via NIP-42. Empty
* when the connection is unauthenticated. Backed by the engine-owned scope
* and read live, so a REQ that arrives after a successful AUTH sees the
* freshly recorded pubkey(s).
*/
val authenticatedUsers: Set<HexKey> get() = (policy as? AuthScopedPolicy)?.authenticatedUsers ?: emptySet()
val authenticatedUsers: Set<HexKey>
}
@@ -1,40 +0,0 @@
/*
* 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.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Opt-in mixin for the policies that actually track NIP-42 authentication, so
* the auth concept stays out of the universal [IRelayPolicy]. A relay that does
* no auth never implements this; only [FullAuthPolicy] (and [PolicyStack], which
* unions any auth-tracking members) do.
*
* The engine surfaces it to the data plane through
* [com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext.authenticatedUsers],
* which downcasts the connection's policy to this interface — so a source sees
* the authenticated pubkey(s) without the base policy interface knowing about
* NIP-42.
*/
interface AuthScopedPolicy {
/** The pubkeys that have authenticated on this connection via NIP-42. */
val authenticatedUsers: Set<HexKey>
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.RandomInstance
@@ -40,11 +41,13 @@ import com.vitorpamplona.quartz.utils.TimeUtils
*
* Implements the full NIP-42 challenge/verify handshake: [onConnect] sends the
* [challenge] and [accept] (AuthCmd) validates the returned event (expiration,
* freshness, challenge match, relay match). Crucially, [accept] does NOT mutate
* state — the pubkey is recorded in [authenticatedUsers] only by [onAuthenticated],
* which the engine calls once [accept] *and* the rest of the policy chain have
* approved the AUTH. That single, late commit is why there is no rollback to
* reason about: a rejected AUTH simply never reaches it.
* freshness, challenge match, relay match). This policy runs the auth *logic*
* but does not *own* the authenticated-identity store: the engine-owned
* connection [scope] holds it. [accept] does NOT mutate state, and
* [onAuthenticated] only votes `true` (after [authorize]) — the engine performs
* the single recording into the scope once the whole chain has approved. Gating
* decisions read [scope].`authenticatedUsers`. A rejected AUTH simply never
* reaches [onAuthenticated], so there is no rollback to reason about.
*
* To bridge to an external auth system, override [authorize] (a `suspend` hook)
* and do the post-verification I/O there — e.g. exchange the verified event for
@@ -53,18 +56,34 @@ import com.vitorpamplona.quartz.utils.TimeUtils
*/
open class FullAuthPolicy(
val relay: NormalizedRelayUrl,
) : IRelayPolicy,
AuthScopedPolicy {
) : IRelayPolicy {
/** The challenge string sent to this client for NIP-42 authentication. */
val challenge: String = RandomInstance.randomChars(32)
/** Set of pubkeys that have successfully authenticated on this session. */
override val authenticatedUsers = mutableSetOf<HexKey>()
/**
* The engine-owned connection scope, captured at [onConnect]. Read-only
* here: this policy reads [RequestContext.authenticatedUsers] to gate, while
* the engine is the only writer. Held safely because a [FullAuthPolicy] is
* built fresh per connection.
*/
private lateinit var scope: RequestContext
/** Returns true if at least one pubkey has authenticated. */
/**
* The pubkeys authenticated on this connection, read from the engine-owned
* scope. Exposed to subclasses so they can gate or rewrite on the caller's
* identity (restricted content, caller-relative filters) — the same set the
* data plane sees via [RequestContext.authenticatedUsers].
*/
protected val authenticatedUsers: Set<HexKey> get() = scope.authenticatedUsers
/** Returns true if at least one pubkey has authenticated on this connection. */
fun isAuthenticated(): Boolean = authenticatedUsers.isNotEmpty()
override fun onConnect(send: (Message) -> Unit) {
override fun onConnect(
scope: RequestContext,
send: (Message) -> Unit,
) {
this.scope = scope
send(AuthMessage(challenge))
}
@@ -91,19 +110,18 @@ open class FullAuthPolicy(
}
/**
* Commits the authentication. The engine calls this only after [accept] and
* the whole policy chain have approved the AUTH, so this is the single point
* where the pubkey is recorded. It runs [authorize] first (which may throw
* to reject) and records the pubkey only on success — the connection is
* never left authenticated behind a failing `OK`. `final`: override
* [authorize], not this.
* Votes to record the authentication. The engine calls this only after
* [accept] and the whole policy chain have approved the AUTH. It runs
* [authorize] first (which may throw to reject — the engine then records
* nothing) and returns `true` so the engine records [pubKey] into the
* connection scope. `final`: override [authorize], not this.
*/
final override suspend fun onAuthenticated(
pubKey: HexKey,
event: RelayAuthEvent,
) {
): Boolean {
authorize(pubKey, event)
authenticatedUsers.add(pubKey)
return true
}
/**
@@ -28,13 +28,24 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
/**
* Defines custom behavior for this relay.
*/
interface IRelayPolicy {
fun onConnect(send: (Message) -> Unit)
/**
* Called once when the connection opens. [scope] is the engine-owned,
* read-only connection scope (id + authenticated users) — a per-connection
* policy may retain it to make later auth-aware decisions; shared singleton
* policies must ignore it and stay stateless. [send] pushes a message to the
* client (e.g. a NIP-42 AUTH challenge).
*/
fun onConnect(
scope: RequestContext,
send: (Message) -> Unit,
)
/**
* Evaluates whether an incoming EVENT command should be accepted.
@@ -70,24 +81,30 @@ interface IRelayPolicy {
/**
* Called once an AUTH command has been [accept]ed by this policy *and* the
* rest of the policy chain, before the success `OK` is sent. This is where
* a policy commits the authentication and/or runs post-verification side
* effects that need network or disk I/O — e.g. exchanging the verified
* NIP-42 event for a backend session token — without leaking that logic into
* the transport layer.
* rest of the policy chain, before the success `OK` is sent. Run any
* post-verification side effects that need network or disk I/O here — e.g.
* exchanging the verified NIP-42 event for a backend session token — without
* leaking that logic into the transport layer.
*
* The engine — not the policy — owns the authenticated-identity store. The
* return value is this policy's vote on whether [pubKey] should be recorded
* as authenticated on the connection: return `true` only if this policy
* actually verified the identity. The default returns `false`, so a policy
* that does not authenticate (e.g. a pass-through or a blind-accept) never
* causes an unverified pubkey to be recorded.
*
* Because it runs only after the whole chain approved the AUTH, throwing
* here cleanly fails the login: the AUTH becomes `OK false` and, since the
* commit lives here too, the connection is never left authenticated. The
* default implementation does nothing.
* here cleanly fails the login: the AUTH becomes `OK false` and the engine
* records nothing.
*
* @param pubKey The pubkey being authenticated.
* @param event The verified NIP-42 auth event.
* @return `true` to have the engine record [pubKey] as authenticated.
*/
suspend fun onAuthenticated(
pubKey: HexKey,
event: RelayAuthEvent,
) {}
): Boolean = false
/**
* Inspects a raw inbound message before it is parsed. Return a reason
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
/**
* Convenience base that accepts everything by default. Subclasses
@@ -37,7 +38,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
* instantiate this directly.
*/
open class PassThroughPolicy : IRelayPolicy {
override fun onConnect(send: (Message) -> Unit) {}
override fun onConnect(
scope: RequestContext,
send: (Message) -> Unit,
) {}
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> = PolicyResult.Accepted(cmd)
@@ -28,20 +28,19 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
class PolicyStack(
vararg policies: IRelayPolicy,
) : IRelayPolicy,
AuthScopedPolicy {
) : IRelayPolicy {
val policies = policies.toList()
/** Union of the authenticated pubkeys across the auth-tracking members. */
override val authenticatedUsers: Set<HexKey>
get() = policies.filterIsInstance<AuthScopedPolicy>().flatMapTo(mutableSetOf()) { it.authenticatedUsers }
override fun onConnect(send: (Message) -> Unit) {
policies.forEach { it.onConnect(send) }
override fun onConnect(
scope: RequestContext,
send: (Message) -> Unit,
) {
policies.forEach { it.onConnect(scope, send) }
}
override fun accept(cmd: EventCmd) = runPolicies(cmd) { p, c -> p.accept(c) }
@@ -55,8 +54,10 @@ class PolicyStack(
override suspend fun onAuthenticated(
pubKey: HexKey,
event: RelayAuthEvent,
) {
policies.forEach { it.onAuthenticated(pubKey, event) }
): Boolean {
// Run every member (side effects) and record iff any one verified the
// identity. `fold` keeps the call on the left so no member is skipped.
return policies.fold(false) { recorded, p -> p.onAuthenticated(pubKey, event) || recorded }
}
override fun acceptMessage(message: String): String? = policies.firstNotNullOfOrNull { it.acceptMessage(message) }
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext
/**
* Verifies the Schnorr signature + id hash of every incoming
@@ -43,7 +44,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
open class VerifyEventsAndAuthPolicy(
private val verifyEvents: Boolean,
) : IRelayPolicy {
override fun onConnect(send: (Message) -> Unit) { }
override fun onConnect(
scope: RequestContext,
send: (Message) -> Unit,
) { }
override fun accept(cmd: EventCmd) =
if (!verifyEvents || cmd.event.verify()) {
@@ -168,7 +168,7 @@ class NostrServerAuthTest {
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains(",true,"))
assertTrue((session.policy as FullAuthPolicy).isAuthenticated())
assertTrue(session.policy.authenticatedUsers.contains(pubkey))
assertTrue(session.requestContext.authenticatedUsers.contains(pubkey))
server.close()
}
@@ -313,7 +313,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[0].contains(",true,"))
assertTrue(okMessages[1].contains(",true,"))
val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers
val authedPubkeys = session.requestContext.authenticatedUsers
assertEquals(2, authedPubkeys.size)
assertTrue(authedPubkeys.contains(pubkey))
assertTrue(authedPubkeys.contains(pubkey2))
@@ -596,7 +596,7 @@ class NostrServerAuthTest {
// still-authenticated connection would be an auth bypass.
val authPolicy = session.policy as FullAuthPolicy
assertFalse(authPolicy.isAuthenticated())
assertFalse(authPolicy.authenticatedUsers.contains(pubkey))
assertFalse(session.requestContext.authenticatedUsers.contains(pubkey))
server.close()
}
@@ -653,7 +653,7 @@ class NostrServerAuthTest {
assertEquals(1, ok.size)
assertTrue(ok[0].contains(",false,"))
assertFalse(auth.isAuthenticated())
assertFalse(auth.authenticatedUsers.contains(pubkey))
assertFalse(session.requestContext.authenticatedUsers.contains(pubkey))
// And a privileged REQ is still gated.
session.receive("""["REQ","sub1",{"kinds":[1]}]""")
@@ -686,12 +686,12 @@ class NostrServerAuthTest {
val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage
session.receive(authJson(authEvent(challenge = msg.challenge)))
assertTrue(auth.authenticatedUsers.contains(pubkey))
assertTrue(session.requestContext.authenticatedUsers.contains(pubkey))
// Second AUTH for the SAME pubkey, rejected downstream.
session.receive(authJson(authEvent(challenge = msg.challenge)))
assertTrue(auth.isAuthenticated())
assertTrue(auth.authenticatedUsers.contains(pubkey))
assertTrue(session.requestContext.authenticatedUsers.contains(pubkey))
server.close()
}