Add shared auth and identity APIs

This commit is contained in:
2026-03-16 21:07:26 +01:00
parent 987415d80c
commit 769177a63e
16 changed files with 590 additions and 27 deletions

View File

@@ -0,0 +1,62 @@
defmodule Parrhesia.API.AuthTest do
use ExUnit.Case, async: true
alias Parrhesia.API.Auth
alias Parrhesia.Protocol.EventValidator
test "validate_event delegates to event validation" do
assert {:error, :invalid_shape} = Auth.validate_event(%{})
end
test "compute_event_id matches the protocol event validator" do
event = %{
"pubkey" => String.duplicate("a", 64),
"created_at" => System.system_time(:second),
"kind" => 1,
"tags" => [],
"content" => "hello",
"sig" => String.duplicate("b", 128)
}
assert Auth.compute_event_id(event) == EventValidator.compute_id(event)
end
test "validate_nip98 returns shared auth context" do
url = "http://example.com/management"
event = nip98_event("POST", url)
header = "Nostr " <> Base.encode64(JSON.encode!(event))
assert {:ok, auth_context} = Auth.validate_nip98(header, "POST", url)
assert auth_context.pubkey == event["pubkey"]
assert auth_context.auth_event["id"] == event["id"]
assert auth_context.request_context.caller == :http
assert MapSet.member?(auth_context.request_context.authenticated_pubkeys, event["pubkey"])
assert auth_context.metadata == %{method: "POST", url: url}
end
test "validate_nip98 accepts custom freshness window" do
url = "http://example.com/management"
event = nip98_event("POST", url, %{"created_at" => System.system_time(:second) - 120})
header = "Nostr " <> Base.encode64(JSON.encode!(event))
assert {:error, :stale_event} = Auth.validate_nip98(header, "POST", url)
assert {:ok, _context} = Auth.validate_nip98(header, "POST", url, max_age_seconds: 180)
end
defp nip98_event(method, url, overrides \\ %{}) do
now = System.system_time(:second)
base = %{
"pubkey" => String.duplicate("a", 64),
"created_at" => now,
"kind" => 27_235,
"tags" => [["method", method], ["u", url]],
"content" => "",
"sig" => String.duplicate("b", 128)
}
base
|> Map.merge(overrides)
|> Map.put("id", EventValidator.compute_id(Map.merge(base, overrides)))
end
end

View File

@@ -0,0 +1,75 @@
defmodule Parrhesia.API.IdentityTest do
use ExUnit.Case, async: false
alias Parrhesia.API.Auth
alias Parrhesia.API.Identity
test "ensure generates and persists a server identity" do
path = unique_identity_path()
assert {:error, :identity_not_found} = Identity.get(path: path)
assert {:ok, %{pubkey: pubkey, source: :generated}} = Identity.ensure(path: path)
assert File.exists?(path)
assert {:ok, %{pubkey: ^pubkey, source: :persisted}} = Identity.get(path: path)
assert {:ok, %{pubkey: ^pubkey, source: :persisted}} = Identity.ensure(path: path)
end
test "import persists an explicit secret key and sign_event uses it" do
path = unique_identity_path()
secret_key = String.duplicate("1", 64)
expected_pubkey =
secret_key
|> Base.decode16!(case: :lower)
|> Secp256k1.pubkey(:xonly)
|> Base.encode16(case: :lower)
assert {:ok, %{pubkey: ^expected_pubkey, source: :imported}} =
Identity.import(%{secret_key: secret_key}, path: path)
assert {:ok, %{pubkey: ^expected_pubkey, source: :persisted}} = Identity.get(path: path)
event = %{
"created_at" => System.system_time(:second),
"kind" => 22_242,
"tags" => [],
"content" => "identity-auth"
}
assert {:ok, signed_event} = Identity.sign_event(event, path: path)
assert signed_event["pubkey"] == expected_pubkey
assert signed_event["id"] == Auth.compute_event_id(signed_event)
signature = Base.decode16!(signed_event["sig"], case: :lower)
event_id = Base.decode16!(signed_event["id"], case: :lower)
pubkey = Base.decode16!(signed_event["pubkey"], case: :lower)
assert Secp256k1.schnorr_valid?(signature, event_id, pubkey)
end
test "rotate rejects configured identities and sign_event validates shape" do
path = unique_identity_path()
secret_key = String.duplicate("2", 64)
assert {:error, :configured_identity_cannot_rotate} =
Identity.rotate(path: path, configured_private_key: secret_key)
assert {:error, :invalid_event} = Identity.sign_event(%{"kind" => 1}, path: path)
end
defp unique_identity_path do
path =
Path.join(
System.tmp_dir!(),
"parrhesia_identity_#{System.unique_integer([:positive, :monotonic])}.json"
)
on_exit(fn ->
_ = File.rm(path)
end)
path
end
end

View File

@@ -19,6 +19,7 @@ defmodule Parrhesia.ApplicationTest do
end)
assert is_pid(Process.whereis(Parrhesia.Auth.Challenges))
assert is_pid(Process.whereis(Parrhesia.API.Identity.Manager))
if negentropy_enabled?() do
assert is_pid(Process.whereis(Parrhesia.Negentropy.Sessions))

View File

@@ -25,7 +25,18 @@ defmodule Parrhesia.Auth.Nip98Test do
Nip98.validate_authorization_header(header, "POST", "http://example.com/other")
end
defp nip98_event(method, url) do
test "supports overriding the freshness window" do
url = "http://example.com/management"
event = nip98_event("POST", url, %{"created_at" => System.system_time(:second) - 120})
header = "Nostr " <> Base.encode64(JSON.encode!(event))
assert {:error, :stale_event} = Nip98.validate_authorization_header(header, "POST", url)
assert {:ok, _event} =
Nip98.validate_authorization_header(header, "POST", url, max_age_seconds: 180)
end
defp nip98_event(method, url, overrides \\ %{}) do
now = System.system_time(:second)
base = %{
@@ -37,6 +48,7 @@ defmodule Parrhesia.Auth.Nip98Test do
"sig" => String.duplicate("b", 128)
}
Map.put(base, "id", EventValidator.compute_id(base))
event = Map.merge(base, overrides)
Map.put(event, "id", EventValidator.compute_id(event))
end
end

View File

@@ -216,6 +216,37 @@ defmodule Parrhesia.Web.RouterTest do
assert principal == String.duplicate("c", 64)
end
test "POST /management supports identity methods" do
management_url = "http://www.example.com/management"
auth_event = nip98_event("POST", management_url)
authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event))
conn =
conn(
:post,
"/management",
JSON.encode!(%{
"method" => "identity_ensure",
"params" => %{}
})
)
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", authorization)
|> Router.call([])
assert conn.status == 200
assert %{
"ok" => true,
"result" => %{
"pubkey" => pubkey
}
} = JSON.decode!(conn.resp_body)
assert is_binary(pubkey)
assert byte_size(pubkey) == 64
end
defp nip98_event(method, url) do
now = System.system_time(:second)