Add shared auth and identity APIs
This commit is contained in:
62
test/parrhesia/api/auth_test.exs
Normal file
62
test/parrhesia/api/auth_test.exs
Normal 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
|
||||
75
test/parrhesia/api/identity_test.exs
Normal file
75
test/parrhesia/api/identity_test.exs
Normal 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
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user