mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
feat(nip46): handle auth_url challenges in the remote-signer client
Adds NIP-46 `auth_url` (web-authorization) support to quartz's remote
signer, so amy (and Amethyst) can use auth-requiring bunkers like
nsec.app / nsecbunker:
- Both BunkerResponse deserializers (kotlinx + the JVM/Android Jackson
one actually used by OptimizedJsonMapper) now special-case
`{result:"auth_url", error:<url>}` BEFORE the generic error branch,
which previously flattened it to a plain error and dropped the marker.
- RemoteSignerManager gained an `onAuthUrl` callback and a wait loop: on
an auth_url response it surfaces the URL once and keeps waiting for the
real response under the same request id (UNLIMITED channel so both are
buffered). newResponse peeks the pending entry (get, not remove) so the
follow-up isn't dropped; the waiter still removes it in finally.
- NostrSignerRemote forwards `onAuthUrl`; amy's Context prints the URL to
stderr and the pending command keeps waiting.
Tests: a deserializer test (auth_url keeps result+error) and a manager
test (auth_url surfaced via callback, then the real response resumes the
request) — both green; existing duplicate/late-response manager tests
still pass after the get-vs-remove change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
This commit is contained in:
+1
-1
@@ -231,7 +231,7 @@ Interop-tested against the real [`nak`](https://github.com/fiatjaf/nak) binary:
|
||||
- **bunker:// both directions** — `amy login bunker://` ⇄ `nak bunker`, and `nak event --sec bunker://` ⇄ `amy bunker`.
|
||||
- **nostrconnect:// client** — `amy login --nostrconnect` ⇄ `nak bunker connect` (amy signs, event authored by nak's key).
|
||||
|
||||
Supports `connect` (secret-checked), `get_public_key`, `get_relays`, `sign_event`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `ping`. `auth_url` challenges are not implemented.
|
||||
Supports `connect` (secret-checked), `get_public_key`, `get_relays`, `sign_event`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `ping`. When a bunker answers with an `auth_url` challenge, amy prints the authorization URL to stderr and keeps waiting for the real response (open the URL in a browser to authorize).
|
||||
|
||||
Example (two terminals, shared `$HOME`):
|
||||
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network.
|
||||
| `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). |
|
||||
| `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. |
|
||||
| `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. |
|
||||
| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction. Interop-verified vs real `nak`. `auth_url` challenge still pending. |
|
||||
| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. |
|
||||
| `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. |
|
||||
|
||||
**Tier 1 status:** shipped — `decode`, `encode`, `verify`, `key`, `event`,
|
||||
|
||||
@@ -118,6 +118,8 @@ class Context(
|
||||
relays = b.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet(),
|
||||
client = client,
|
||||
secret = b.connectSecret,
|
||||
// Bunker requires web authorization: surface the URL; the request keeps waiting.
|
||||
onAuthUrl = { url -> System.err.println("[nip46] authorize this request in a browser, then it will continue:\n $url") },
|
||||
)
|
||||
} ?: NostrSignerInternal(identity.keyPair())
|
||||
|
||||
|
||||
+6
-1
@@ -24,4 +24,9 @@ open class BunkerResponse(
|
||||
val id: String,
|
||||
val result: String?,
|
||||
val error: String?,
|
||||
) : BunkerMessage()
|
||||
) : BunkerMessage() {
|
||||
companion object {
|
||||
/** NIP-46 auth-challenge marker: `result == "auth_url"`, with the URL carried in `error`. */
|
||||
const val RESULT_AUTH_URL = "auth_url"
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -75,6 +75,14 @@ object BunkerResponseKSerializer : KSerializer<BunkerResponse> {
|
||||
val result = jsonObject["result"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content }
|
||||
val error = jsonObject["error"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content }
|
||||
|
||||
// NIP-46 auth challenge: `{"result":"auth_url","error":"<url>"}`. It carries
|
||||
// an `error` (the URL) but is NOT a failure — keep both fields so the client
|
||||
// can surface the URL and keep waiting for the real response. Must precede the
|
||||
// generic error branch below, which would otherwise drop the `auth_url` marker.
|
||||
if (result == BunkerResponse.RESULT_AUTH_URL) {
|
||||
return BunkerResponse(id, result, error)
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
return BunkerResponseError.parse(id, result, error)
|
||||
}
|
||||
|
||||
+7
@@ -57,6 +57,12 @@ class NostrSignerRemote(
|
||||
val client: INostrClient,
|
||||
val permissions: String? = null,
|
||||
val secret: String? = null,
|
||||
/**
|
||||
* Invoked with the authorization URL when the bunker answers with a NIP-46
|
||||
* `auth_url` challenge. Surface it (open a browser / print it); the pending
|
||||
* request keeps waiting for the real response.
|
||||
*/
|
||||
val onAuthUrl: ((String) -> Unit)? = null,
|
||||
) : NostrSigner(signer.pubKey) {
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
@@ -66,6 +72,7 @@ class NostrSignerRemote(
|
||||
remoteKey = remotePubkey,
|
||||
relayList = relays,
|
||||
client = client,
|
||||
onAuthUrl = onAuthUrl,
|
||||
)
|
||||
|
||||
val subscription =
|
||||
|
||||
+32
-3
@@ -41,6 +41,13 @@ class RemoteSignerManager(
|
||||
val remoteKey: String,
|
||||
val relayList: Set<NormalizedRelayUrl>,
|
||||
val maxRetries: Int = 1,
|
||||
/**
|
||||
* Invoked with the authorization URL when the bunker answers a request with
|
||||
* a NIP-46 `auth_url` challenge. The caller should surface it (open a
|
||||
* browser / print it) so the user can authorize; the manager keeps waiting
|
||||
* for the real response on the same request id.
|
||||
*/
|
||||
val onAuthUrl: ((String) -> Unit)? = null,
|
||||
) {
|
||||
private val pending = LargeCache<String, Channel<BunkerResponse>>()
|
||||
|
||||
@@ -48,7 +55,10 @@ class RemoteSignerManager(
|
||||
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
|
||||
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
|
||||
|
||||
val channel = pending.remove(bunkerResponse.id)
|
||||
// Peek (don't remove): a request may receive an `auth_url` challenge
|
||||
// followed by the real response under the same id. The waiting
|
||||
// continuation removes the entry in its `finally`.
|
||||
val channel = pending.get(bunkerResponse.id)
|
||||
if (channel == null) {
|
||||
Log.d("NIP46") { "no channel for bunker response id=${bunkerResponse.id} (duplicate, unknown, or late)" }
|
||||
return
|
||||
@@ -89,13 +99,32 @@ class RemoteSignerManager(
|
||||
signer = signer,
|
||||
)
|
||||
|
||||
val channel = Channel<BunkerResponse>(capacity = 1)
|
||||
// UNLIMITED so an `auth_url` challenge and the follow-up real
|
||||
// response (same id) can both be buffered without dropping either.
|
||||
val channel = Channel<BunkerResponse>(capacity = Channel.UNLIMITED)
|
||||
pending.put(attemptRequest.id, channel)
|
||||
|
||||
var announcedAuthUrl: String? = null
|
||||
val response =
|
||||
try {
|
||||
client.publish(event, relayList = relayList)
|
||||
withTimeoutOrNull(timeout) { channel.receive() }
|
||||
withTimeoutOrNull(timeout) {
|
||||
var real: BunkerResponse? = null
|
||||
while (real == null) {
|
||||
val r = channel.receive()
|
||||
if (r.result == BunkerResponse.RESULT_AUTH_URL) {
|
||||
// Surface the auth URL once and keep waiting for the real response.
|
||||
val url = r.error
|
||||
if (url != null && url != announcedAuthUrl) {
|
||||
announcedAuthUrl = url
|
||||
onAuthUrl?.invoke(url)
|
||||
}
|
||||
} else {
|
||||
real = r
|
||||
}
|
||||
}
|
||||
real
|
||||
}
|
||||
} finally {
|
||||
pending.remove(attemptRequest.id)
|
||||
channel.close()
|
||||
|
||||
+57
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
@@ -72,6 +73,62 @@ class RemoteSignerManagerRetryTest {
|
||||
signer = bunkerSigner,
|
||||
)
|
||||
|
||||
private suspend fun bunkerAuthUrlFor(
|
||||
requestId: String,
|
||||
url: String,
|
||||
): NostrConnectEvent =
|
||||
NostrConnectEvent.create(
|
||||
message = BunkerResponse(requestId, BunkerResponse.RESULT_AUTH_URL, url),
|
||||
remoteKey = signer.pubKey,
|
||||
signer = bunkerSigner,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun authUrlResponseKeepsBothResultAndError() {
|
||||
val json = """{"id":"abc","result":"auth_url","error":"https://signer.example/auth?x=1"}"""
|
||||
val resp = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(json)
|
||||
// Must NOT be flattened into a plain error — the auth_url marker has to survive.
|
||||
assertEquals(BunkerResponse.RESULT_AUTH_URL, resp.result)
|
||||
assertEquals("https://signer.example/auth?x=1", resp.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authUrlChallengeIsSurfacedThenRealResponseResumes() =
|
||||
runTest {
|
||||
val capturing = CapturingNostrClient()
|
||||
val seenUrls = mutableListOf<String>()
|
||||
val manager =
|
||||
RemoteSignerManager(
|
||||
timeout = 5_000,
|
||||
client = capturing,
|
||||
signer = signer,
|
||||
remoteKey = remoteKey,
|
||||
relayList = setOf(relay),
|
||||
maxRetries = 0,
|
||||
onAuthUrl = { seenUrls.add(it) },
|
||||
)
|
||||
|
||||
val deferred =
|
||||
async {
|
||||
manager.launchWaitAndParse(
|
||||
bunkerRequestBuilder = { BunkerRequestPing() },
|
||||
parser = PingResponse::parse,
|
||||
)
|
||||
}
|
||||
runCurrent()
|
||||
|
||||
val requestId = decodeRequestId(capturing.publishedEvents.single())
|
||||
// The bunker first asks for web authorization, then (after the user
|
||||
// authorizes) sends the real response under the same id.
|
||||
manager.newResponse(bunkerAuthUrlFor(requestId, "https://signer.example/auth"))
|
||||
manager.newResponse(bunkerPongFor(requestId))
|
||||
advanceUntilIdle()
|
||||
|
||||
val result = deferred.await()
|
||||
assertIs<SignerResult.RequestAddressed.Successful<PingResult>>(result)
|
||||
assertEquals(listOf("https://signer.example/auth"), seenUrls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timeoutReturnsTimedOutAfterMaxRetries() =
|
||||
runTest {
|
||||
|
||||
+8
@@ -44,6 +44,14 @@ class BunkerResponseDeserializer : StdDeserializer<BunkerResponse>(BunkerRespons
|
||||
val result = jsonObject.get("result")?.asText()
|
||||
val error = jsonObject.get("error")?.asText()
|
||||
|
||||
// NIP-46 auth challenge: `{"result":"auth_url","error":"<url>"}`. It carries
|
||||
// an `error` (the URL) but is NOT a failure — keep both fields so the client
|
||||
// can surface the URL and keep waiting for the real response. Must precede the
|
||||
// generic error branch below, which would otherwise drop the `auth_url` marker.
|
||||
if (result == BunkerResponse.RESULT_AUTH_URL) {
|
||||
return BunkerResponse(id, result, error)
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
return BunkerResponseError.parse(id, result, error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user