8.2 KiB
Auth identity is connection scope, not policy state
Date: 2026-06-04
Module: quartz — nip01Core/relay/server
Status: Proposed (review before implementing)
Problem
IRelayPolicy is a decision interface (accept(...) → allow/reject/rewrite).
But we attached authenticatedUsers (the set of pubkeys logged in on a
connection) to the policy world via the AuthScopedPolicy mixin. That is
connection state, not a decision. Storing it in the policy forced three
artifacts whose only job is to route the state back out as scope:
AuthScopedPolicy— marker to locate the state,PolicyStack.authenticatedUsers— union to forward it through the composition wrapper (the session's real policy is usuallyPolicyStack(LimitsPolicy, …)),RequestContext'sas? AuthScopedPolicydowncast — to read it back.
RequestContext is already the connection-scope object (connectionId,
authenticatedUsers). It should own the authenticated identity instead of
reaching into the policy for it.
Why it got merged
The gating policies need the state to decide: FullAuthPolicy.accept(ReqCmd)
returns auth-required when no one is authenticated. Storing the set in the
policy kept accept() self-contained. The fix is to let the policy read a
scope it doesn't own.
Target model
- Connection scope (engine-owned, per
RelaySession) owns the mutableauthenticatedUsersset +connectionId.RequestContextis its read-only view (what sources already receive). - Policy stays pure decision.
FullAuthPolicykeepsaccept(AuthCmd)(validate the proof) and gatingaccept(ReqCmd/CountCmd/EventCmd), but reads the scope to gate instead of owning a set. - The engine performs the single commit. On a fully-approved AUTH, the engine records the verified pubkey into the scope — one commit point, not a field buried in a policy.
Deleted by this change: AuthScopedPolicy, PolicyStack.authenticatedUsers,
the RequestContext downcast, and FullAuthPolicy's authenticatedUsers field
/ isAuthenticated() / the .add() in onAuthenticated.
The two correctness invariants (must be preserved)
- Only a verified identity may enter the scope. A blind-accept policy
(
PassThroughPolicy/EmptyPolicy, which acceptAUTHwithout checking a challenge or signature) must record nothing. Today this holds because onlyFullAuthPolicy.onAuthenticatedcommits. We must not regress to "commit wheneveracceptpassed." - No partial/rolled-back auth. A thrown
authorize, or a downstream policy rejecting the AUTH, must leave the connection unauthenticated. Guarded byauthorizeThrowTurnsAuthIntoFailingOk,policyRejectingAuthAfterFullAuthLeavesConnectionUnauthenticated,failedReAuthKeepsPreviousValidAuthentication,commandsRejectedAfterFailedAuthHook.
Hard constraint: VerifyPolicy/VerifyAuthOnlyPolicy/EmptyPolicy are
shared object singletons. They must remain stateless — a per-connection scope
ref may only be retained by a per-connection policy (built fresh by
policyBuilder, i.e. FullAuthPolicy).
Mechanism
Two small signature changes carry it:
A. Read side — inject the scope at connect
IRelayPolicy.onConnect(send) → onConnect(scope: RequestContext, send).
PassThroughPolicy,VerifyEventsAndAuthPolicy(singletons): ignorescope(no storage — stays stateless).PolicyStack.onConnect: forwardscopeto each child.FullAuthPolicy: store the read-onlyscope(per-connection, safe) and readscope.authenticatedUsersin its gatingaccept(...).
B. Write side — onAuthenticated returns the commit decision
IRelayPolicy.onAuthenticated(pubKey, event) → returns Boolean
(default false = "I did not authenticate anyone; record nothing").
FullAuthPolicy: runsauthorize(...)(may throw → engine catches →OK false), thenreturn true. No.add().PolicyStack: run every child (side effects) and OR the results, so a chain authenticates iff some verifying member claims it:policies.fold(false) { acc, p -> p.onAuthenticated(pubKey, event) || acc }(left operand always evaluated → every hook runs).- Engine (
RelaySession.handleAuth):val result = policy.accept(cmd) // proof check across chain if (rejected) { OK false; return } val record = try { policy.onAuthenticated(cmd.event.pubKey, cmd.event) } catch (e) { OK false; return } // authorize threw → nothing recorded if (record) scope.add(cmd.event.pubKey) // single engine-side commit OK true
This keeps the write strictly engine-side (invariant 1: only true-returning
verifying policies cause a commit; PassThrough returns false) and the commit
after a no-throw onAuthenticated (invariant 2).
File-by-file
| File | Change |
|---|---|
backend/RequestContext.kt |
Drop the as? AuthScopedPolicy getter; authenticatedUsers becomes a plain backed property. Keep policy (for app-state downcast). |
policies/AuthScopedPolicy.kt |
Delete. |
policies/IRelayPolicy.kt |
onConnect gains scope: RequestContext; onAuthenticated returns Boolean (default false). |
policies/FullAuthPolicy.kt |
Remove authenticatedUsers field, isAuthenticated(), the marker. Store injected scope; gate on scope.authenticatedUsers; onAuthenticated runs authorize then return true. |
policies/PolicyStack.kt |
Drop authenticatedUsers/marker; forward scope in onConnect; OR-fold onAuthenticated. |
policies/PassThroughPolicy.kt, VerifyPolicy.kt |
onConnect(scope, send) — ignore scope (stay stateless). |
server/RelaySession.kt |
Own the mutable authenticatedUsers set behind requestContext; expose requestContext (read view) for observability/tests; pass it to policy.onConnect; perform the commit in handleAuth. |
No change to EventSource/SessionBackend/EventSourceBackend/LiveEventStore
— sources still get RequestContext and read authenticatedUsers exactly as
now; only the backing moved.
Test mapping (assertions retarget; guarantees unchanged)
NostrServerAuthTest currently inspects (session.policy as FullAuthPolicy) .authenticatedUsers / .isAuthenticated(). Retarget to the scope, e.g.
session.requestContext.authenticatedUsers (expose requestContext on
RelaySession).
| Test | New path / why it still holds |
|---|---|
authSucceedsWithValidEvent |
accept ✓ → onAuthenticated→true → engine commits → requestContext.authenticatedUsers has pubkey. |
multipleUsersCanAuthenticate |
two successful AUTHs → engine commits each → set has both. |
authorizeThrowTurnsAuthIntoFailingOk |
authorize throws → engine catches before commit → set empty. |
policyRejectingAuthAfterFullAuthLeavesConnectionUnauthenticated |
chain accept rejected → onAuthenticated never called → no commit. |
failedReAuthKeepsPreviousValidAuthentication |
2nd accept rejected → no commit → set keeps 1st pubkey. |
commandsRejectedAfterFailedAuthHook |
unchanged (gating reads empty scope). |
EventSourceServerTest.sourceSeesAuthenticatedUserInContext |
unchanged — already reads ctx.authenticatedUsers. |
Alternatives considered
- Scope exposes
add(), verifying policy writes it (noonAuthenticatedsignature change). Rejected: gives policies write access to scope and splits the commit across N policies; the engine-side single commit is easier to audit against invariant 2. - Pass
scopeinto everyaccept(cmd, scope)instead of injecting atonConnect. Rejected: ~4 methods × ~9 policies of churn for a dependency onlyFullAuthPolicyuses. - Generic
EventSource<C : RequestContext>for a typedAuthRequestContext(prior discussion). Out of scope; cascades type params through the sharedSessionBackend/storage path.
Risk
Low surface, high-sensitivity (signed-in correctness). The four bypass tests are
the spec; they pass unchanged in behavior, only their assertion target moves.
The onConnect/onAuthenticated signature changes are mechanical but touch
every policy + any external IRelayPolicy impl (source-breaking — acceptable
for this in-tree 2025 SPI, same call as the EventSource ctx change).