diff --git a/CHANGELOG.md b/CHANGELOG.md index 4052c18..82a14ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to ### Breaking Changes -- The `Atex.IdentityResolver` config key has been replaced with a flat config option. - Update your config from: +- The `Atex.IdentityResolver` config key has been replaced with a flat config + option. Update your config from: ```elixir config :atex, Atex.IdentityResolver, @@ -27,16 +27,25 @@ and this project adheres to - `Atex.Config.IdentityResolver` has been renamed to `Atex.Config`. - `Atex.IdentityResolver.DIDDocument` has been renamed to `Atex.DID.Document`. -- Replace existing `Atex.DID.Document.new/1` method with the method previously named `from_json/1`. +- Replace existing `Atex.DID.Document.new/1` method with the method previously + named `from_json/1`. ### Added -- `Atex.Crypto` module for performing AT Protocol-related cryptographic operations. -- `Atex.PLC` module for interacting with [a did:plc directory API](https://web.plc.directory/). -- `Atex.ServiceAuth` module for validating [inter-service authentication tokens](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). +- `Atex.Crypto` module for performing AT Protocol-related cryptographic + operations. +- `Atex.PLC` module for interacting with + [a did:plc directory API](https://web.plc.directory/). +- `Atex.ServiceAuth` module for validating + [inter-service authentication tokens](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). - Various improvements to `Atex.Did.Document` - - Add `Atex.DID.Document.Service` and `Atex.DID.Document.VerificationMethod` sub-structs. - - Add `to_json/1` methods and `JSON.Encoder` protocols for easy conversion to camelCase JSON. + - Add `Atex.DID.Document.Service` and `Atex.DID.Document.VerificationMethod` + sub-structs. + - Add `to_json/1` methods and `JSON.Encoder` protocols for easy conversion to + camelCase JSON. +- `Atex.XRPC.Router` module with `query/3` and `procedure/3` macros for easily + building XRPC server routes inside a `Plug.Router`, with built-in service auth + validation and validation if passed the name of a module using `deflexicon`. - `deflexicon` now emits `content_type/0` functions (on `Input` submodules for typed JSON bodies, otherwise on the root module) for procedures. diff --git a/README.md b/README.md index 7400690..d87d928 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ An Elixir toolkit for the [AT Protocol](https://atproto.com). - [ ] Repository reading and manipulation (MST & CAR) - [x] Service auth - [x] PLC client -- [ ] XRPC server router +- [x] XRPC server router Looking to use a data subscription service like the Firehose, [Jetstream], or [Tap]? Check out [Drinkup]. diff --git a/config/runtime.exs b/config/runtime.exs index a3eb500..dd6b5c4 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -10,4 +10,5 @@ config :atex, Atex.OAuth, key_id: "awooga" config :atex, - plc_directory_url: "https://plc.directory" + plc_directory_url: "https://plc.directory", + service_did: "did:web:setsuna.prawn-galaxy.ts.net" diff --git a/examples/service_auth.ex b/examples/service_auth.ex index 783e3a2..c783ad0 100644 --- a/examples/service_auth.ex +++ b/examples/service_auth.ex @@ -1,6 +1,7 @@ defmodule ServiceAuthExample do require Logger use Plug.Router + use Atex.XRPC.Router plug :match plug :dispatch @@ -37,11 +38,21 @@ defmodule ServiceAuthExample do |> send_resp(200, @did_doc) end - get "/xrpc/com.ovyerus.example" do + query "com.example.test" do IO.inspect(conn) + conn |> send_resp(200, "test") + end - conn - |> send_resp(200, "") + # See `./service_auth` for module & lexicon definitions. + query Com.Example.GetProfile do + IO.inspect(conn.assigns, label: "getProfile") + conn |> send_resp(200, "test") + end + + # TODO: why did body not validate + procedure Com.Example.CreatePost, require_auth: true do + IO.inspect(conn.assigns, label: "createPost") + conn |> send_resp(200, "test") end match _ do diff --git a/examples/service_auth/atproto/com/example/createPost.ex b/examples/service_auth/atproto/com/example/createPost.ex new file mode 100644 index 0000000..629dde0 --- /dev/null +++ b/examples/service_auth/atproto/com/example/createPost.ex @@ -0,0 +1,72 @@ +defmodule Com.Example.CreatePost do + @moduledoc false + use Atex.Lexicon + + deflexicon(%{ + "defs" => %{ + "main" => %{ + "description" => "Creates a post record and returns its AT-URI and CID.", + "errors" => [ + %{ + "description" => "The post body failed content validation.", + "name" => "InvalidContent" + } + ], + "input" => %{ + "encoding" => "application/json", + "schema" => %{"ref" => "#postInput", "type" => "ref"} + }, + "output" => %{ + "encoding" => "application/json", + "schema" => %{ + "properties" => %{ + "cid" => %{"format" => "cid", "type" => "string"}, + "uri" => %{"format" => "at-uri", "type" => "string"} + }, + "required" => ["uri", "cid"], + "type" => "object" + } + }, + "parameters" => %{ + "properties" => %{ + "validate" => %{ + "default" => true, + "description" => "When false, skip Lexicon validation of the post body.", + "type" => "boolean" + } + }, + "type" => "params" + }, + "type" => "procedure" + }, + "postInput" => %{ + "description" => "Input body for creating a post.", + "properties" => %{ + "createdAt" => %{ + "description" => + "Client-supplied creation timestamp. Defaults to server time if omitted.", + "format" => "datetime", + "type" => "string" + }, + "langs" => %{ + "description" => "BCP-47 language tags describing the content language(s).", + "items" => %{"format" => "language", "type" => "string"}, + "maxLength" => 3, + "type" => "array" + }, + "text" => %{ + "description" => "The plain-text content of the post.", + "maxGraphemes" => 300, + "maxLength" => 3000, + "type" => "string" + } + }, + "required" => ["text"], + "type" => "object" + } + }, + "description" => "Create a new post in a user's repository.", + "id" => "com.example.createPost", + "lexicon" => 1 + }) +end diff --git a/examples/service_auth/atproto/com/example/getProfile.ex b/examples/service_auth/atproto/com/example/getProfile.ex new file mode 100644 index 0000000..eba7ab2 --- /dev/null +++ b/examples/service_auth/atproto/com/example/getProfile.ex @@ -0,0 +1,58 @@ +defmodule Com.Example.GetProfile do + @moduledoc false + use Atex.Lexicon + + deflexicon(%{ + "defs" => %{ + "main" => %{ + "description" => "Returns profile information for the specified account.", + "errors" => [ + %{ + "description" => "No account exists for the given actor.", + "name" => "AccountNotFound" + } + ], + "output" => %{ + "encoding" => "application/json", + "schema" => %{"ref" => "#profileView", "type" => "ref"} + }, + "parameters" => %{ + "properties" => %{ + "actor" => %{ + "description" => "The DID or handle of the account to fetch.", + "format" => "at-identifier", + "type" => "string" + } + }, + "required" => ["actor"], + "type" => "params" + }, + "type" => "query" + }, + "profileView" => %{ + "description" => "A public view of a user profile.", + "properties" => %{ + "avatar" => %{"format" => "uri", "type" => "string"}, + "createdAt" => %{"format" => "datetime", "type" => "string"}, + "description" => %{ + "maxGraphemes" => 256, + "maxLength" => 2560, + "type" => "string" + }, + "did" => %{"format" => "did", "type" => "string"}, + "displayName" => %{ + "maxGraphemes" => 64, + "maxLength" => 640, + "type" => "string" + }, + "handle" => %{"format" => "handle", "type" => "string"} + }, + "required" => ["did", "handle"], + "type" => "object" + } + }, + "description" => "Fetch a user profile by DID or handle.", + "id" => "com.example.getProfile", + "lexicon" => 1 + }) +end diff --git a/examples/service_auth/atproto/com/example/uploadBlob.ex b/examples/service_auth/atproto/com/example/uploadBlob.ex new file mode 100644 index 0000000..94cb843 --- /dev/null +++ b/examples/service_auth/atproto/com/example/uploadBlob.ex @@ -0,0 +1,47 @@ +defmodule Com.Example.UploadBlob do + @moduledoc false + use Atex.Lexicon + + deflexicon(%{ + "defs" => %{ + "main" => %{ + "description" => + "Accepts a raw binary body and stores it as a blob. The Content-Type header must be set to the MIME type of the uploaded data.", + "errors" => [ + %{ + "description" => + "The Content-Type of the uploaded data is not an accepted image MIME type.", + "name" => "InvalidMimeType" + }, + %{ + "description" => "The uploaded blob exceeds the maximum permitted size.", + "name" => "BlobTooLarge" + } + ], + "input" => %{ + "description" => + "Raw binary content of the blob. Supported MIME types: image/jpeg, image/png, image/gif, image/webp.", + "encoding" => "*/*" + }, + "output" => %{ + "encoding" => "application/json", + "schema" => %{ + "properties" => %{ + "blob" => %{ + "accept" => ["image/*"], + "maxSize" => 1_000_000, + "type" => "blob" + } + }, + "required" => ["blob"], + "type" => "object" + } + }, + "type" => "procedure" + } + }, + "description" => "Upload a binary blob (e.g. an image) and receive a blob reference.", + "id" => "com.example.uploadBlob", + "lexicon" => 1 + }) +end diff --git a/examples/service_auth/lexicon/com.example.createPost.json b/examples/service_auth/lexicon/com.example.createPost.json new file mode 100644 index 0000000..07bbf19 --- /dev/null +++ b/examples/service_auth/lexicon/com.example.createPost.json @@ -0,0 +1,78 @@ +{ + "lexicon": 1, + "id": "com.example.createPost", + "description": "Create a new post in a user's repository.", + "defs": { + "main": { + "type": "procedure", + "description": "Creates a post record and returns its AT-URI and CID.", + "parameters": { + "type": "params", + "properties": { + "validate": { + "type": "boolean", + "default": true, + "description": "When false, skip Lexicon validation of the post body." + } + } + }, + "input": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#postInput" + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri" + }, + "cid": { + "type": "string", + "format": "cid" + } + } + } + }, + "errors": [ + { + "name": "InvalidContent", + "description": "The post body failed content validation." + } + ] + }, + "postInput": { + "type": "object", + "description": "Input body for creating a post.", + "required": ["text"], + "properties": { + "text": { + "type": "string", + "maxGraphemes": 300, + "maxLength": 3000, + "description": "The plain-text content of the post." + }, + "langs": { + "type": "array", + "items": { + "type": "string", + "format": "language" + }, + "maxLength": 3, + "description": "BCP-47 language tags describing the content language(s)." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-supplied creation timestamp. Defaults to server time if omitted." + } + } + } + } +} diff --git a/examples/service_auth/lexicon/com.example.getProfile.json b/examples/service_auth/lexicon/com.example.getProfile.json new file mode 100644 index 0000000..b2b01d2 --- /dev/null +++ b/examples/service_auth/lexicon/com.example.getProfile.json @@ -0,0 +1,68 @@ +{ + "lexicon": 1, + "id": "com.example.getProfile", + "description": "Fetch a user profile by DID or handle.", + "defs": { + "main": { + "type": "query", + "description": "Returns profile information for the specified account.", + "parameters": { + "type": "params", + "required": ["actor"], + "properties": { + "actor": { + "type": "string", + "format": "at-identifier", + "description": "The DID or handle of the account to fetch." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#profileView" + } + }, + "errors": [ + { + "name": "AccountNotFound", + "description": "No account exists for the given actor." + } + ] + }, + "profileView": { + "type": "object", + "description": "A public view of a user profile.", + "required": ["did", "handle"], + "properties": { + "did": { + "type": "string", + "format": "did" + }, + "handle": { + "type": "string", + "format": "handle" + }, + "displayName": { + "type": "string", + "maxGraphemes": 64, + "maxLength": 640 + }, + "description": { + "type": "string", + "maxGraphemes": 256, + "maxLength": 2560 + }, + "avatar": { + "type": "string", + "format": "uri" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } +} diff --git a/examples/service_auth/lexicon/com.example.uploadBlob.json b/examples/service_auth/lexicon/com.example.uploadBlob.json new file mode 100644 index 0000000..a8cd887 --- /dev/null +++ b/examples/service_auth/lexicon/com.example.uploadBlob.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "com.example.uploadBlob", + "description": "Upload a binary blob (e.g. an image) and receive a blob reference.", + "defs": { + "main": { + "type": "procedure", + "description": "Accepts a raw binary body and stores it as a blob. The Content-Type header must be set to the MIME type of the uploaded data.", + "input": { + "encoding": "*/*", + "description": "Raw binary content of the blob. Supported MIME types: image/jpeg, image/png, image/gif, image/webp." + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["blob"], + "properties": { + "blob": { + "type": "blob", + "accept": ["image/*"], + "maxSize": 1000000 + } + } + } + }, + "errors": [ + { + "name": "InvalidMimeType", + "description": "The Content-Type of the uploaded data is not an accepted image MIME type." + }, + { + "name": "BlobTooLarge", + "description": "The uploaded blob exceeds the maximum permitted size." + } + ] + } + } +} diff --git a/lib/atex/config.ex b/lib/atex/config.ex index 940d9d7..81ae3cc 100644 --- a/lib/atex/config.ex +++ b/lib/atex/config.ex @@ -7,10 +7,14 @@ defmodule Atex.Config do The following keys are supported under `config :atex`: config :atex, - plc_directory_url: "https://plc.directory" + plc_directory_url: "https://plc.directory", + service_did: "did:web:my-service.example" - `:plc_directory_url` - Base URL for the did:plc directory server. Defaults to `"https://plc.directory"`. + - `:service_did` - The DID of this service, used as the expected `aud` claim + when validating incoming inter-service auth JWTs via `Atex.XRPC.Router`. + Required when using `Atex.XRPC.Router` with auth enabled. """ @doc """ @@ -22,4 +26,12 @@ defmodule Atex.Config do @spec directory_url :: String.t() def directory_url, do: Application.get_env(:atex, :plc_directory_url, "https://plc.directory") + + @doc """ + Returns the configured service DID to be used for validation service auth tokens. + + Reads `:service_did` from the `:atex application environment. + """ + @spec service_did :: String.t() | nil + def service_did, do: Application.get_env(:atex, :service_did) end diff --git a/lib/atex/service_auth.ex b/lib/atex/service_auth.ex index 146f8cb..898bf9b 100644 --- a/lib/atex/service_auth.ex +++ b/lib/atex/service_auth.ex @@ -57,8 +57,8 @@ defmodule Atex.ServiceAuth do def validate_conn(conn, opts \\ []) do case get_req_header(conn, "authorization") do ["Bearer " <> jwt] -> validate_jwt(jwt, opts) - [_] -> :error - _ -> :error + [_] -> {:error, :no_header} + _ -> {:error, :no_header} end end diff --git a/lib/atex/xrpc/login_client.ex b/lib/atex/xrpc/login_client.ex index 4a40aaa..4d82cee 100644 --- a/lib/atex/xrpc/login_client.ex +++ b/lib/atex/xrpc/login_client.ex @@ -114,8 +114,6 @@ defmodule Atex.XRPC.LoginClient do @spec handle_failure(t(), Req.Response.t(), Req.Request.t()) :: {:ok, Req.Response.t(), t()} | {:error, any()} defp handle_failure(client, response, request) do - IO.inspect(response, label: "got failure") - if auth_error?(response.body) and client.refresh_token do case refresh(client) do {:ok, client} -> diff --git a/lib/atex/xrpc/router.ex b/lib/atex/xrpc/router.ex new file mode 100644 index 0000000..38d5fd7 --- /dev/null +++ b/lib/atex/xrpc/router.ex @@ -0,0 +1,417 @@ +defmodule Atex.XRPC.Router do + @moduledoc """ + Routing utilities for building ATProto XRPC server endpoints. + + Provides the `query/3` and `procedure/3` macros that expand to + `Plug.Router.get/3` and `Plug.Router.post/3` respectively, with built-in + handling for: + + - NSID-prefixed route paths (`/xrpc/`) + - Service auth validation via `Atex.ServiceAuth` + - Query param and body validation for lexicon modules generated with `Atex.Lexicon.deflexicon/1`. + + ## Usage + + defmodule MyAPI do + use Plug.Router + use Atex.XRPC.Router + + plug :match + plug :dispatch + + # Matches GET /xrpc/com.example.getProfile + query "com.example.getProfile" do + send_resp(conn, 200, "ok") + end + + # Matches POST /xrpc/com.example.createPost, enforces auth + procedure Com.Example.CreatePost, require_auth: true do + # conn.assigns[:params] and conn.assigns[:body] are populated + # when the lexicon module defines Params/Input submodules + send_resp(conn, 200, "created") + end + end + + ## Authentication + + Authentication uses `Atex.ServiceAuth.validate_conn/2`. The audience (`aud`) + is read from `conn.private[:xrpc_aud]`, which is populated automatically by + `Atex.XRPC.Router.AudPlug` that reads `:service_did` from app config. To + disable automatic plug injection: + + use Atex.XRPC.Router, plug_aud: false + + When `require_auth: true` is passed to a route macro, a missing or invalid + token halts with a `401` response. Otherwise auth is attempted softly - + on success the decoded JWT is placed at `conn.assigns[:current_jwt]`, on + failure the conn is left untouched. + + ## Validation + + When a lexicon module atom is passed, the macro checks at compile time whether + `.Params` and/or `.Input` exist. If they do, their + `from_json/1` is called at request time: + + - Valid params → `conn.assigns[:params]` + - Valid body → `conn.assigns[:body]` + - Either failing → halts with a `400` response + """ + + @doc false + defmacro __using__(opts \\ []) do + plug_aud = Keyword.get(opts, :plug_aud, true) + + quote do + import Atex.XRPC.Router, only: [query: 2, query: 3, procedure: 2, procedure: 3] + + if unquote(plug_aud) do + plug Atex.XRPC.Router.AudPlug + end + end + end + + @doc """ + Defines a GET route for an XRPC query. + + The first argument is either: + + - A plain string NSID (e.g. `"com.example.getProfile"`) - validated at + compile time. + - A lexicon module atom (e.g. `Com.Example.GetProfile`) - the NSID is + fetched from `module.id()` at compile time. + + ## Options + + - `:require_auth` - when `true`, requests without a valid service auth token + are rejected with a `401`. Defaults to `false`. + + ## Assigns + + - `:current_jwt` - the decoded `JOSE.JWT` struct, set on successful auth. + - `:params` - validated params struct, set when the lexicon module + defines a `Params` submodule. + + ## Examples + + query "com.example.getTimeline", require_auth: true do + send_resp(conn, 200, "ok") + end + + query Com.Example.GetTimeline do + send_resp(conn, 200, "ok") + end + """ + defmacro query(nsid_or_module, opts \\ [], do: block) do + {nsid, params_module} = resolve_nsid_and_submodule(nsid_or_module, :Params, __CALLER__) + require_auth = Keyword.get(opts, :require_auth, false) + path = "/xrpc/#{nsid}" + + auth_block = build_auth_block(nsid, require_auth) + params_block = if params_module, do: build_params_block(params_module), else: [] + + quote do + get unquote(path) do + var!(conn) = Plug.Conn.fetch_query_params(var!(conn)) + unquote_splicing(auth_block) + unquote_splicing(params_block) + + if var!(conn).halted do + var!(conn) + else + unquote(block) + end + end + end + end + + @doc """ + Defines a POST route for an XRPC procedure. + + The first argument is either: + + - A plain string NSID (e.g. `"com.example.createPost"`) - validated at + compile time. + - A lexicon module atom (e.g. `Com.Example.CreatePost`) - the NSID is + fetched from `module.id()` at compile time. + + ## Options + + - `:require_auth` - when `true`, requests without a valid service auth token + are rejected with a `401`. Defaults to `false`. + + ## Assigns + + - `:current_jwt` - the decoded `JOSE.JWT` struct, set on successful auth. + - `:params` - validated params struct, set when the lexicon module + defines a `Params` submodule. + - `:body` - validated input struct, set when the lexicon module + defines an `Input` submodule. + + ## Non-JSON payloads + + If a lexicon procedure defines an `input` with an encoding without an `object` + schema, this will simply validate the incoming `Content-Type` header against the + requested encoding. Nothing happens on success, you will need to read `conn`'s + body as usual and do extra validation yourself, as clients may lie about their content. + Wildcards are handled correctly as per the atproto documentation. + + ## Examples + + procedure "com.example.createPost", require_auth: true do + send_resp(conn, 200, "created") + end + + procedure Com.Example.CreatePost, require_auth: true do + # conn.assigns[:body] contains the validated Input struct + send_resp(conn, 200, "created") + end + """ + defmacro procedure(nsid_or_module, opts \\ [], do: block) do + {nsid, input_module} = resolve_nsid_and_submodule(nsid_or_module, :Input, __CALLER__) + {_nsid, params_module} = resolve_nsid_and_submodule(nsid_or_module, :Params, __CALLER__) + raw_input_module = resolve_raw_input_module(nsid_or_module, input_module, __CALLER__) + require_auth = Keyword.get(opts, :require_auth, false) + path = "/xrpc/#{nsid}" + + auth_block = build_auth_block(nsid, require_auth) + params_block = if params_module, do: build_params_block(params_module), else: [] + + body_block = + cond do + input_module -> build_body_block(input_module) + raw_input_module -> build_raw_body_block(raw_input_module) + true -> [] + end + + quote do + post unquote(path) do + var!(conn) = Plug.Conn.fetch_query_params(var!(conn)) + unquote_splicing(auth_block) + unquote_splicing(params_block) + unquote_splicing(body_block) + + if var!(conn).halted do + var!(conn) + else + unquote(block) + end + end + end + end + + # --------------------------------------------------------------------------- + # Private helpers (compile-time) + # --------------------------------------------------------------------------- + + # Returns the root lexicon module if it represents a raw-input procedure + # (i.e. it exports `content_type/0` but has no `Input` submodule with + # `from_json/1`). Returns `nil` in all other cases, including when + # `nsid_or_module` is a plain string NSID. + @spec resolve_raw_input_module(term(), module() | nil, Macro.Env.t()) :: module() | nil + defp resolve_raw_input_module(nsid_or_module, input_module, env) do + with nil <- input_module, + {:__aliases__, _, _} = ast <- nsid_or_module do + module = Macro.expand(ast, env) + + if Code.ensure_loaded?(module) and function_exported?(module, :content_type, 0) do + module + end + else + _ -> nil + end + end + + # Returns {nsid_string, submodule_atom_or_nil}. + # `submodule` is e.g. :Params or :Input. + @spec resolve_nsid_and_submodule(term(), atom(), Macro.Env.t()) :: + {String.t(), module() | nil} + defp resolve_nsid_and_submodule(nsid_or_module, submodule_suffix, env) do + case nsid_or_module do + nsid when is_binary(nsid) -> + unless Atex.NSID.match?(nsid) do + raise CompileError, + file: env.file, + line: env.line, + description: "invalid NSID: #{inspect(nsid)}" + end + + {nsid, nil} + + {:__aliases__, _, _} = ast -> + module = Macro.expand(ast, env) + + unless Code.ensure_loaded?(module) and function_exported?(module, :id, 0) do + raise CompileError, + file: env.file, + line: env.line, + description: + "#{inspect(module)} does not define id/0 - " <> + "only lexicon modules generated by deflexicon are supported" + end + + nsid = module.id() + + unless Atex.NSID.match?(nsid) do + raise CompileError, + file: env.file, + line: env.line, + description: "#{inspect(module)}.id() returned an invalid NSID: #{inspect(nsid)}" + end + + candidate = Module.concat(module, submodule_suffix) + + sub = + if Code.ensure_loaded?(candidate) and function_exported?(candidate, :from_json, 1) do + candidate + end + + {nsid, sub} + end + end + + # Emits a list of quoted expressions that perform auth (soft + optional strict). + # Uses var!(conn) to pierce macro hygiene and reference the `conn` variable + # introduced by Plug.Router.get/post in the caller's context. + @spec build_auth_block(String.t(), boolean()) :: [Macro.t()] + defp build_auth_block(nsid, require_auth) do + soft_auth = + quote do + var!(conn) = + case Atex.ServiceAuth.validate_conn(var!(conn), + aud: var!(conn).private[:xrpc_aud], + lxm: unquote(nsid) + ) do + {:ok, jwt} -> Plug.Conn.assign(var!(conn), :current_jwt, jwt) + _err -> var!(conn) + end + end + + strict_auth = + if require_auth do + quote do + var!(conn) = + if is_nil(var!(conn).assigns[:current_jwt]) do + var!(conn) + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp( + 401, + Jason.encode!(%{ + "error" => "AuthRequired", + "message" => "Authentication required" + }) + ) + |> Plug.Conn.halt() + else + var!(conn) + end + end + end + + [soft_auth | List.wrap(strict_auth)] + end + + # Emits a quoted expression that validates query params via `module.from_json/1`. + # Skips if the conn is already halted by a previous step. + @spec build_params_block(module()) :: [Macro.t()] + defp build_params_block(params_module) do + [ + quote do + var!(conn) = + if var!(conn).halted do + var!(conn) + else + case unquote(params_module).from_json(var!(conn).query_params) do + {:ok, params} -> + Plug.Conn.assign(var!(conn), :params, params) + + {:error, reason} -> + var!(conn) + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp( + 400, + Jason.encode!(%{ + "error" => "InvalidRequest", + "message" => "Invalid query parameters: #{inspect(reason)}" + }) + ) + |> Plug.Conn.halt() + end + end + end + ] + end + + # Emits a quoted expression that validates the request body via `module.from_json/1`. + # Skips if the conn is already halted by a previous step. + @spec build_body_block(module()) :: [Macro.t()] + defp build_body_block(input_module) do + [ + quote do + var!(conn) = + if var!(conn).halted do + var!(conn) + else + case unquote(input_module).from_json(var!(conn).body_params) do + {:ok, body} -> + Plug.Conn.assign(var!(conn), :body, body) + + {:error, reason} -> + var!(conn) + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp( + 400, + Jason.encode!(%{ + "error" => "InvalidRequest", + "message" => "Invalid request body: #{inspect(reason)}" + }) + ) + |> Plug.Conn.halt() + end + end + end + ] + end + + # Emits a quoted expression that validates the incoming Content-Type header + # against the MIME type declared in the lexicon for a raw (non-JSON) input + # procedure. On success, the raw body is placed at `conn.assigns[:body]`. + # Skips if the conn is already halted by a previous step. + @spec build_raw_body_block(module()) :: [Macro.t()] + defp build_raw_body_block(raw_module) do + [ + quote do + var!(conn) = + if var!(conn).halted do + var!(conn) + else + declared = unquote(raw_module).content_type() + + parsed_content_type = + var!(conn) + |> Plug.Conn.get_req_header("content-type") + |> List.first("") + |> Plug.Conn.Utils.content_type() + + with {:ok, type, subtype, _params} <- parsed_content_type, + actual <- "#{type}/#{subtype}", + true <- + declared == "*/*" or actual == declared or + (String.ends_with?(declared, "/*") and + String.starts_with?(actual, String.trim_trailing(declared, "*"))) do + var!(conn) + else + var!(conn) + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp( + 415, + JSON.encode!(%{ + "error" => "InvalidRequest", + message: "Unsupported media type: expected #{declared}" + }) + ) + end + end + end + ] + end +end diff --git a/lib/atex/xrpc/router/aud_plug.ex b/lib/atex/xrpc/router/aud_plug.ex new file mode 100644 index 0000000..db21964 --- /dev/null +++ b/lib/atex/xrpc/router/aud_plug.ex @@ -0,0 +1,38 @@ +defmodule Atex.XRPC.Router.AudPlug do + @moduledoc """ + Plug that populates `conn.private[:xrpc_aud]` from the `:service_did` app config. + + Injected automatically when using `Atex.XRPC.Router` (unless `plug_aud: false` + is passed to `use`). Raises at runtime if `:service_did` is not configured, + since auth validation requires a non-nil audience. + + ## Configuration + + config :atex, service_did: "did:web:my-service.example" + """ + + import Plug.Conn + + @behaviour Plug + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(conn, _opts) do + aud = + Atex.Config.service_did() || + raise """ + Atex.XRPC.Router.AudPlug: :service_did is not configured. + Add the following to your config: + + config :atex, service_did: "did:web:my-service.example" + + Or disable automatic aud injection with: + + use Atex.XRPC.Router, plug_aud: false + """ + + put_private(conn, :xrpc_aud, aud) + end +end diff --git a/test/atex/lexicon_test.exs b/test/atex/lexicon_test.exs index d1fb657..36a67c7 100644 --- a/test/atex/lexicon_test.exs +++ b/test/atex/lexicon_test.exs @@ -108,6 +108,44 @@ defmodule Atex.LexiconTest do end end + # --------------------------------------------------------------------------- + # Tests: raw-input procedure (encoding only, no schema) + # --------------------------------------------------------------------------- + + describe "procedure with raw input (encoding only)" do + test "does not generate an Input submodule" do + refute Code.ensure_loaded?(Lexicon.Test.UploadBlob.Input) + end + + test "root module exports content_type/0" do + assert function_exported?(Lexicon.Test.UploadBlob, :content_type, 0) + end + + test "content_type/0 returns the declared encoding" do + assert Lexicon.Test.UploadBlob.content_type() == "image/jpeg" + end + + test "root module has raw_input field in struct" do + assert Map.has_key?(%Lexicon.Test.UploadBlob{}, :raw_input) + end + end + + describe "procedure with wildcard raw input encoding" do + test "content_type/0 returns */*" do + assert Lexicon.Test.UploadAny.content_type() == "*/*" + end + end + + describe "procedure with JSON input schema" do + test "Input submodule exports content_type/0" do + assert function_exported?(Lexicon.Test.CreatePost.Input, :content_type, 0) + end + + test "Input.content_type/0 returns the declared encoding" do + assert Lexicon.Test.CreatePost.Input.content_type() == "application/json" + end + end + # --------------------------------------------------------------------------- # Tests: union-typed query output (cross-NSID refs) # --------------------------------------------------------------------------- diff --git a/test/atex/xrpc/router_test.exs b/test/atex/xrpc/router_test.exs new file mode 100644 index 0000000..844332d --- /dev/null +++ b/test/atex/xrpc/router_test.exs @@ -0,0 +1,400 @@ +defmodule Atex.XRPC.RouterTest do + use ExUnit.Case, async: true + + import Plug.Test + import Plug.Conn + + # --------------------------------------------------------------------------- + # Stub lexicon modules used by macro-expansion tests. + # These live outside of the test module so they are available at compile time + # when the inline router modules below are defined. + # --------------------------------------------------------------------------- + + defmodule StubLexicon do + @moduledoc false + def id, do: "com.example.stubQuery" + + defmodule Params do + @moduledoc false + def from_json(%{"name" => name}) when is_binary(name), do: {:ok, %{name: name}} + def from_json(_), do: {:error, "name is required and must be a string"} + end + end + + defmodule StubProcedureLexicon do + @moduledoc false + def id, do: "com.example.stubProcedure" + + defmodule Params do + @moduledoc false + # Query params arrive as strings; version is optional. + def from_json(%{"version" => v}) when is_binary(v) or is_integer(v), + do: {:ok, %{version: v}} + + def from_json(_), do: {:ok, %{}} + end + + defmodule Input do + @moduledoc false + def from_json(%{"text" => t}) when is_binary(t), do: {:ok, %{text: t}} + def from_json(_), do: {:error, "text is required"} + end + end + + defmodule StubNoParamsLexicon do + @moduledoc false + def id, do: "com.example.noParams" + end + + # --------------------------------------------------------------------------- + # Router fixtures + # --------------------------------------------------------------------------- + + defmodule StringNSIDRouter do + use Plug.Router + use Atex.XRPC.Router, plug_aud: false + + plug :match + plug :dispatch + + query "com.example.stringQuery" do + send_resp(conn, 200, "query-ok") + end + + procedure "com.example.stringProcedure" do + send_resp(conn, 200, "procedure-ok") + end + + match _ do + send_resp(conn, 404, "not found") + end + end + + defmodule ModuleRouter do + use Plug.Router + use Atex.XRPC.Router, plug_aud: false + + plug Plug.Parsers, + parsers: [:json], + pass: ["application/json"], + json_decoder: Jason + + plug :match + plug :dispatch + + query Atex.XRPC.RouterTest.StubLexicon do + send_resp(conn, 200, Jason.encode!(conn.assigns[:params])) + end + + procedure Atex.XRPC.RouterTest.StubProcedureLexicon do + result = %{ + params: conn.assigns[:params], + body: conn.assigns[:body] + } + + send_resp(conn, 200, Jason.encode!(result)) + end + + query Atex.XRPC.RouterTest.StubNoParamsLexicon do + has_params = Map.has_key?(conn.assigns, :params) + send_resp(conn, 200, if(has_params, do: "has-params", else: "no-params")) + end + + match _ do + send_resp(conn, 404, "not found") + end + end + + defmodule RequireAuthRouter do + use Plug.Router + use Atex.XRPC.Router, plug_aud: false + + plug :match + plug :dispatch + + query "com.example.authed", require_auth: true do + send_resp(conn, 200, "authed-ok") + end + + query "com.example.softAuth" do + has_jwt = Map.has_key?(conn.assigns, :current_jwt) + send_resp(conn, 200, if(has_jwt, do: "has-jwt", else: "no-jwt")) + end + + match _ do + send_resp(conn, 404, "not found") + end + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp call(router, method, path, opts \\ []) do + headers = Keyword.get(opts, :headers, []) + body = Keyword.get(opts, :body, "") + query_string = Keyword.get(opts, :query_string, "") + + conn = + method + |> conn(path <> if(query_string != "", do: "?#{query_string}", else: ""), body) + |> Map.put(:req_headers, headers) + + conn = + if aud = Keyword.get(opts, :xrpc_aud) do + put_private(conn, :xrpc_aud, aud) + else + conn + end + + router.call(conn, router.init([])) + end + + defp json_body(conn) do + Jason.decode!(conn.resp_body) + end + + # --------------------------------------------------------------------------- + # Tests: string NSID routing + # --------------------------------------------------------------------------- + + describe "query with string NSID" do + test "routes GET /xrpc/" do + conn = call(StringNSIDRouter, :get, "/xrpc/com.example.stringQuery") + assert conn.status == 200 + assert conn.resp_body == "query-ok" + end + + test "does not match POST" do + conn = call(StringNSIDRouter, :post, "/xrpc/com.example.stringQuery") + assert conn.status == 404 + end + + test "does not match unrelated paths" do + conn = call(StringNSIDRouter, :get, "/xrpc/com.example.other") + assert conn.status == 404 + end + end + + describe "procedure with string NSID" do + test "routes POST /xrpc/" do + conn = call(StringNSIDRouter, :post, "/xrpc/com.example.stringProcedure") + assert conn.status == 200 + assert conn.resp_body == "procedure-ok" + end + + test "does not match GET" do + conn = call(StringNSIDRouter, :get, "/xrpc/com.example.stringProcedure") + assert conn.status == 404 + end + end + + # --------------------------------------------------------------------------- + # Tests: module atom routing and param/body validation + # --------------------------------------------------------------------------- + + describe "query with lexicon module (has Params)" do + test "validates and assigns params on success" do + conn = call(ModuleRouter, :get, "/xrpc/com.example.stubQuery", query_string: "name=alice") + assert conn.status == 200 + assert Jason.decode!(conn.resp_body) == %{"name" => "alice"} + end + + test "halts with 400 on invalid params" do + conn = call(ModuleRouter, :get, "/xrpc/com.example.stubQuery") + assert conn.status == 400 + body = json_body(conn) + assert body["error"] == "InvalidRequest" + assert is_binary(body["message"]) + end + end + + describe "query with lexicon module (no Params submodule)" do + test "does not assign :params" do + conn = call(ModuleRouter, :get, "/xrpc/com.example.noParams") + assert conn.status == 200 + assert conn.resp_body == "no-params" + end + end + + describe "procedure with lexicon module (has Params and Input)" do + test "validates and assigns body on success" do + conn = + call(ModuleRouter, :post, "/xrpc/com.example.stubProcedure", + body: Jason.encode!(%{"text" => "hello"}), + headers: [{"content-type", "application/json"}] + ) + + assert conn.status == 200 + result = Jason.decode!(conn.resp_body) + assert result["body"] == %{"text" => "hello"} + end + + test "assigns params when query string present" do + conn = + call(ModuleRouter, :post, "/xrpc/com.example.stubProcedure", + query_string: "version=1", + body: Jason.encode!(%{"text" => "hello"}), + headers: [{"content-type", "application/json"}] + ) + + assert conn.status == 200 + result = Jason.decode!(conn.resp_body) + assert result["params"] == %{"version" => "1"} + end + + test "halts with 400 on invalid body" do + conn = + call(ModuleRouter, :post, "/xrpc/com.example.stubProcedure", + body: Jason.encode!(%{"wrong" => "field"}), + headers: [{"content-type", "application/json"}] + ) + + assert conn.status == 400 + body = json_body(conn) + assert body["error"] == "InvalidRequest" + end + end + + # --------------------------------------------------------------------------- + # Tests: auth behaviour + # --------------------------------------------------------------------------- + + describe "require_auth: true" do + test "returns 401 when no Authorization header is present" do + conn = + call(RequireAuthRouter, :get, "/xrpc/com.example.authed", xrpc_aud: "did:web:example.com") + + assert conn.status == 401 + body = json_body(conn) + assert body["error"] == "AuthRequired" + end + + test "returns 401 when Authorization header is malformed" do + conn = + call(RequireAuthRouter, :get, "/xrpc/com.example.authed", + headers: [{"authorization", "NotBearer bad"}], + xrpc_aud: "did:web:example.com" + ) + + assert conn.status == 401 + body = json_body(conn) + assert body["error"] == "AuthRequired" + end + + test "halts and does not run the block when auth fails" do + conn = + call(RequireAuthRouter, :get, "/xrpc/com.example.authed", xrpc_aud: "did:web:example.com") + + assert conn.halted + end + end + + describe "soft auth (require_auth: false, default)" do + test "does not assign :current_jwt when no Authorization header" do + conn = + call(RequireAuthRouter, :get, "/xrpc/com.example.softAuth", + xrpc_aud: "did:web:example.com" + ) + + assert conn.status == 200 + assert conn.resp_body == "no-jwt" + end + + test "does not halt when no Authorization header" do + conn = + call(RequireAuthRouter, :get, "/xrpc/com.example.softAuth", + xrpc_aud: "did:web:example.com" + ) + + refute conn.halted + end + end + + # --------------------------------------------------------------------------- + # Tests: AudPlug + # --------------------------------------------------------------------------- + + describe "Atex.XRPC.Router.AudPlug" do + test "raises at runtime when :service_did is not configured" do + original = Application.get_env(:atex, :service_did) + + try do + Application.delete_env(:atex, :service_did) + + assert_raise RuntimeError, ~r/:service_did is not configured/, fn -> + conn(:get, "/") + |> Atex.XRPC.Router.AudPlug.call([]) + end + after + if original do + Application.put_env(:atex, :service_did, original) + end + end + end + + test "puts :service_did into conn.private[:xrpc_aud]" do + original = Application.get_env(:atex, :service_did) + + try do + Application.put_env(:atex, :service_did, "did:web:test.example") + + conn = + conn(:get, "/") + |> Atex.XRPC.Router.AudPlug.call([]) + + assert conn.private[:xrpc_aud] == "did:web:test.example" + after + if original do + Application.put_env(:atex, :service_did, original) + else + Application.delete_env(:atex, :service_did) + end + end + end + end + + # --------------------------------------------------------------------------- + # Tests: compile-time NSID validation + # --------------------------------------------------------------------------- + + describe "invalid NSID string" do + test "raises CompileError at macro expansion" do + assert_raise CompileError, ~r/invalid NSID/, fn -> + Code.compile_string(""" + defmodule BadNSIDRouter do + use Plug.Router + use Atex.XRPC.Router, plug_aud: false + plug :match + plug :dispatch + query "not-a-valid-nsid" do + send_resp(conn, 200, "") + end + end + """) + end + end + end + + describe "module without id/0" do + test "raises CompileError at macro expansion" do + assert_raise CompileError, ~r/does not define id\/0/, fn -> + Code.compile_string(""" + defmodule NoIdModule do + def something, do: :ok + end + + defmodule BadModuleRouter do + use Plug.Router + use Atex.XRPC.Router, plug_aud: false + plug :match + plug :dispatch + query NoIdModule do + send_resp(conn, 200, "") + end + end + """) + end + end + end +end diff --git a/test/support/lexicon_fixtures.ex b/test/support/lexicon_fixtures.ex index 7d14f67..2f2d7e0 100644 --- a/test/support/lexicon_fixtures.ex +++ b/test/support/lexicon_fixtures.ex @@ -169,6 +169,46 @@ defmodule Lexicon.Test.CreateUnion do }) end +# Procedure with a raw (non-JSON) input - encoding only, no schema. +# NSID "lexicon.test.uploadBlob" -> Lexicon.Test.UploadBlob +defmodule Lexicon.Test.UploadBlob do + @moduledoc false + use Atex.Lexicon + + deflexicon(%{ + "lexicon" => 1, + "id" => "lexicon.test.uploadBlob", + "defs" => %{ + "main" => %{ + "type" => "procedure", + "input" => %{ + "encoding" => "image/jpeg" + } + } + } + }) +end + +# Procedure with a wildcard raw input encoding. +# NSID "lexicon.test.uploadAny" -> Lexicon.Test.UploadAny +defmodule Lexicon.Test.UploadAny do + @moduledoc false + use Atex.Lexicon + + deflexicon(%{ + "lexicon" => 1, + "id" => "lexicon.test.uploadAny", + "defs" => %{ + "main" => %{ + "type" => "procedure", + "input" => %{ + "encoding" => "*/*" + } + } + } + }) +end + # Query whose output.schema is a `union` of two cross-NSID refs. # NSID "lexicon.test.getUnion" -> Lexicon.Test.GetUnion defmodule Lexicon.Test.GetUnion do