···3131### Added
32323333- `Atex.PLC` module for interacting with [a did:plc directory API](https://web.plc.directory/).
3434+- `Atex.ServiceAuth` module for validating [inter-service authentication tokens](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
34353536### Fixed
3637
+3-2
README.md
···11# atex
2233-An Elxir toolkit for the [AT Protocol](https://atproto.com).
33+An Elixir toolkit for the [AT Protocol](https://atproto.com).
4455## Feature map
66···1515- [x] XRPC client
1616 - With integration for generated Lexicon structs!
1717- [ ] Repository reading and manipulation (MST & CAR)
1818-- [ ] Service auth
1818+- [x] Service auth
1919- [x] PLC client
2020+- [ ] XRPC server router
20212122Looking to use a data subscription service like the Firehose, [Jetstream], or [Tap]? Check out [Drinkup].
2223
···201201 _ -> {:error, :sign_failed}
202202 end
203203204204+ def generate_p256() do
205205+ JOSE.JWK.generate_key({:ec, "P-256"})
206206+ end
207207+208208+ def generate_k256() do
209209+ JOSE.JWK.generate_key({:ec, "secp256k1"})
210210+ end
211211+204212 # Private helpers
205213206214 @spec strip_did_key_prefix(String.t()) :: String.t()
+1-1
lib/atex/identity_resolver/identity.ex
···1212 @typedoc """
1313 The resolved DID document for an identity.
1414 """
1515- @type document() :: Atex.IdentityResolver.DIDDocument.t()
1515+ @type document() :: Atex.PLC.DIDDocument.t()
16161717 typedstruct do
1818 field :did, did(), enforce: true
+21
lib/atex/plc/did_document.ex
···136136 end
137137 end
138138139139+ @spec get_atproto_signing_key(t()) :: JOSE.JWK.t() | nil
140140+ def get_atproto_signing_key(%__MODULE__{} = doc) do
141141+ doc.verification_method
142142+ |> Enum.find(fn
143143+ %{id: id} -> String.ends_with?(id, "#atproto")
144144+ end)
145145+ |> case do
146146+ nil ->
147147+ nil
148148+149149+ %{public_key_multibase: multibase} ->
150150+ {:ok, jwk} = Atex.Crypto.decode_did_key(multibase)
151151+ jwk
152152+153153+ # TODO
154154+ _ ->
155155+ raise ArgumentError, message: "Legacy verification method keys are not yet supported"
156156+ # %{public_key_jwk: jwk} -> nil
157157+ end
158158+ end
159159+139160 defp valid_pds_endpoint?(endpoint) do
140161 case URI.new(endpoint) do
141162 {:ok, uri} ->
+182
lib/atex/service_auth.ex
···11+defmodule Atex.ServiceAuth do
22+ @moduledoc """
33+ Validating and working with inter-service authentication tokens.
44+55+ Provides functions for validating [ATProto inter-service authentication JWTs](https://atproto.com/specs/xrpc#inter-service-authentication-jwt),
66+ either from a raw token string or directly from an incoming `Plug.Conn`.
77+88+ Validation covers:
99+1010+ - Token timing (`iat` not in the future, `exp` not in the past).
1111+ - Audience (`aud`) matching the caller-supplied expected value.
1212+ - Optional lexicon method (`lxm`) matching the caller-supplied expected value.
1313+ - Issuer DID resolution and signing-key verification via `Atex.IdentityResolver`.
1414+ - Replay prevention via `Atex.ServiceAuth.JTICache` - each `jti` nonce may
1515+ only be accepted once.
1616+1717+ ## Configuration
1818+1919+ The JTI cache implementation is pluggable. See `Atex.ServiceAuth.JTICache` for
2020+ details.
2121+ """
2222+2323+ import Plug.Conn
2424+2525+ @typedoc """
2626+ Options accepted by `validate_conn/2` and `validate_jwt/2`.
2727+2828+ - `:aud` - **required**. The expected audience string. The token's `aud` claim
2929+ must equal this value exactly.
3030+ - `:lxm` - optional. When provided, the token's `lxm` claim must match. If
3131+ the token omits `lxm` the check is skipped; if the token carries `lxm` but
3232+ no expected value is configured, validation fails with `:lxm_not_configured`.
3333+ """
3434+ @type validate_option() :: {:lxm, String.t()} | {:aud, String.t()}
3535+3636+ @doc """
3737+ Validate a service auth token from a `Plug.Conn` request.
3838+3939+ Extracts the `Authorization: Bearer <jwt>` header and delegates to
4040+ `validate_jwt/2`. Returns `{:error, :missing_token}` when the header is
4141+ absent or malformed.
4242+4343+ ## Options
4444+4545+ See `t:validate_option/0`.
4646+4747+ ## Examples
4848+4949+ iex> Atex.ServiceAuth.validate_conn(conn, aud: "did:web:my-service.example")
5050+ {:ok, %JOSE.JWT{}}
5151+5252+ iex> Atex.ServiceAuth.validate_conn(conn, aud: "did:web:my-service.example", lxm: "app.bsky.feed.getTimeline")
5353+ {:error, :lxm_mismatch}
5454+ """
5555+ @spec validate_conn(Plug.Conn.t(), list(validate_option())) ::
5656+ {:ok, jwt :: JOSE.JWT.t()} | {:error, reason :: atom()}
5757+ def validate_conn(conn, opts \\ []) do
5858+ case get_req_header(conn, "authorization") do
5959+ ["Bearer " <> jwt] -> validate_jwt(jwt, opts)
6060+ [_] -> :error
6161+ _ -> :error
6262+ end
6363+ end
6464+6565+ @doc """
6666+ Validate a raw service auth JWT string.
6767+6868+ Performs the full validation pipeline:
6969+7070+ 1. Decodes the JWT payload (without verifying the signature yet) to extract claims.
7171+ 2. Validates `:aud` and `:lxm` against the provided options.
7272+ 3. Validates token timing (`iat`, `exp`).
7373+ 4. Resolves the issuer DID and retrieves the ATProto signing key from their
7474+ DID document.
7575+ 5. Verifies the JWT signature with the resolved key.
7676+ 6. Records the `jti` nonce in `Atex.ServiceAuth.JTICache` - returns
7777+ `{:error, :replayed_token}` if it has already been seen.
7878+7979+ ## Options
8080+8181+ See `t:validate_option/0`.
8282+8383+ ## Error reasons
8484+8585+ - `:aud_mismatch` - `aud` claim does not match the expected audience.
8686+ - `:lxm_mismatch` - `lxm` claim does not match the expected lexicon method.
8787+ - `:lxm_not_configured` - token carries an `lxm` claim but no expected value
8888+ was provided via `:lxm` opt.
8989+ - `:future_iat` - `iat` is in the future.
9090+ - `:expired` - `exp` is in the past.
9191+ - `:replayed_token` - `jti` has already been used.
9292+9393+ ## Examples
9494+9595+ iex> Atex.ServiceAuth.validate_jwt(jwt, aud: "did:web:my-service.example")
9696+ {:ok, %JOSE.JWT{}}
9797+9898+ iex> Atex.ServiceAuth.validate_jwt(expired_jwt, aud: "did:web:my-service.example")
9999+ {:error, :expired}
100100+ """
101101+ @spec validate_jwt(String.t(), list(validate_option())) ::
102102+ {:ok, jwt :: JOSE.JWT.t()} | {:error, reason :: atom()}
103103+ def validate_jwt(jwt, opts \\ []) do
104104+ {expected_aud, expected_lxm} = options(opts)
105105+106106+ %{
107107+ fields:
108108+ %{
109109+ "aud" => target_aud,
110110+ "iat" => iat,
111111+ "exp" => exp,
112112+ "iss" => issuing_did,
113113+ "jti" => nonce
114114+ } = fields
115115+ } = JOSE.JWT.peek(jwt)
116116+117117+ target_lxm = Map.get(fields, "lxm")
118118+119119+ with :ok <- validate_aud(expected_aud, target_aud),
120120+ :ok <- validate_lxm(expected_lxm, target_lxm),
121121+ :ok <- validate_token_times(iat, exp),
122122+ # Resolve JWT's issuer to: a) make sure it's a real identity, b) get
123123+ # the signing key from their DID document to verify the token
124124+ {:ok, identity} <- Atex.IdentityResolver.resolve(issuing_did),
125125+ user_jwk when not is_nil(user_jwk) <-
126126+ Atex.PLC.DIDDocument.get_atproto_signing_key(identity.document),
127127+ {true, %JOSE.JWT{} = jwt_struct, _jws} <- JOSE.JWT.verify(user_jwk, jwt),
128128+ # Record the nonce atomically after successful verification. insert_new
129129+ # is used under the hood so this returns :seen if the jti was already
130130+ # consumed, preventing replay attacks.
131131+ :ok <- Atex.ServiceAuth.JTICache.put(nonce, exp) do
132132+ {:ok, jwt_struct}
133133+ else
134134+ :seen -> {:error, :replayed_token}
135135+ err -> err
136136+ end
137137+ end
138138+139139+ @spec validate_token_times(integer(), integer()) :: :ok | {:error, reason :: atom()}
140140+ defp validate_token_times(iat, exp) do
141141+ now = DateTime.utc_now()
142142+143143+ with {:ok, iat} <- DateTime.from_unix(iat),
144144+ {:ok, exp} <- DateTime.from_unix(exp) do
145145+ cond do
146146+ DateTime.before?(now, iat) ->
147147+ {:error, :future_iat}
148148+149149+ DateTime.after?(now, exp) ->
150150+ {:error, :expired}
151151+152152+ true ->
153153+ :ok
154154+ end
155155+ end
156156+ end
157157+158158+ @spec validate_aud(String.t(), String.t()) :: :ok | {:error, reason :: atom()}
159159+ defp validate_aud(expected, target) when expected == target, do: :ok
160160+ defp validate_aud(_expected, _target), do: {:error, :aud_mismatch}
161161+162162+ @spec validate_lxm(String.t() | nil, String.t() | nil) :: :ok | {:error, reason :: atom()}
163163+ defp validate_lxm(expected, target) when expected == target, do: :ok
164164+ # `lxm` in JWTs is currently optional so we can do this.
165165+ # TODO: should have an option to force requirement (e.g. for security-sensitive operations)
166166+ defp validate_lxm(expected, nil) when is_binary(expected), do: :ok
167167+ defp validate_lxm(nil, target) when is_binary(target), do: {:error, :lxm_not_configured}
168168+ defp validate_lxm(expected, target) when expected != target, do: {:error, :lxm_mismatch}
169169+170170+ @spec options(list(validate_option())) :: {aud :: String.t(), lxm :: String.t() | nil}
171171+ defp options(opts) do
172172+ opts = Keyword.validate!(opts, aud: nil, lxm: nil)
173173+ aud = Keyword.get(opts, :aud)
174174+ lxm = Keyword.get(opts, :lxm)
175175+176176+ if !aud do
177177+ raise ArgumentError, "`:aud` option is required for service auth validation"
178178+ end
179179+180180+ {aud, lxm}
181181+ end
182182+end
+52
lib/atex/service_auth/jti_cache.ex
···11+defmodule Atex.ServiceAuth.JTICache do
22+ @moduledoc """
33+ Behaviour and compile-time dispatch for tracking used `jti` (JWT ID) nonces
44+ from service auth tokens, preventing replay attacks.
55+66+ Implementations are responsible for:
77+88+ - Storing a `jti` alongside its expiry so that entries can be evicted once
99+ the corresponding token has naturally expired (avoiding unbounded growth).
1010+ - Returning `:seen` when a `jti` has already been recorded, and `:ok` when it
1111+ is new (and recording it atomically).
1212+1313+ ## Configuration
1414+1515+ The active implementation is resolved at compile time:
1616+1717+ ```elixir
1818+ config :atex, :jti_cache, Atex.ServiceAuth.JTICache.ETS
1919+ ```
2020+2121+ Defaults to `Atex.ServiceAuth.JTICache.ETS` when not configured.
2222+ """
2323+2424+ @cache Application.compile_env(:atex, :jti_cache, Atex.ServiceAuth.JTICache.ETS)
2525+2626+ @doc """
2727+ Record a `jti` as seen. The implementation must store it until at least
2828+ `expires_at` (a Unix timestamp integer) so that expired tokens cannot be
2929+ replayed before the entry is evicted.
3030+3131+ Returns `:ok` if this is the first time the `jti` has been seen, or `:seen`
3232+ if it was already present.
3333+ """
3434+ @callback put(jti :: String.t(), expires_at :: integer()) :: :ok | :seen
3535+3636+ @doc """
3737+ Check whether a `jti` has already been seen without modifying the cache.
3838+3939+ Returns `:ok` if unseen, `:seen` if already present.
4040+ """
4141+ @callback get(jti :: String.t()) :: :ok | :seen
4242+4343+ @doc """
4444+ Get the child specification for starting the cache in a supervision tree.
4545+ """
4646+ @callback child_spec(any()) :: Supervisor.child_spec()
4747+4848+ defdelegate put(jti, expires_at), to: @cache
4949+ defdelegate get(jti), to: @cache
5050+ @doc false
5151+ defdelegate child_spec(opts), to: @cache
5252+end
+72
lib/atex/service_auth/jti_cache/ets.ex
···11+defmodule Atex.ServiceAuth.JTICache.ETS do
22+ @moduledoc """
33+ ConCache-backed implementation of `Atex.ServiceAuth.JTICache`.
44+55+ Each `jti` is stored with a per-item TTL derived from the token's own `exp`
66+ claim, so entries are evicted automatically once the corresponding token could
77+ no longer be presented as valid. This keeps memory use proportional to the
88+ number of currently-live tokens rather than growing without bound.
99+1010+ The TTL check interval defaults to 30 seconds and can be overridden:
1111+1212+ ```elixir
1313+ config :atex, Atex.ServiceAuth.JTICache.ETS, ttl_check_interval: :timer.seconds(10)
1414+ ```
1515+ """
1616+1717+ @behaviour Atex.ServiceAuth.JTICache
1818+ use Supervisor
1919+2020+ @cache :atex_service_auth_jti_cache
2121+ @default_ttl_check_interval :timer.seconds(30)
2222+2323+ def start_link(opts) do
2424+ Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
2525+ end
2626+2727+ @impl Supervisor
2828+ def init(_opts) do
2929+ ttl_check_interval =
3030+ Application.get_env(:atex, __MODULE__, [])
3131+ |> Keyword.get(:ttl_check_interval, @default_ttl_check_interval)
3232+3333+ children = [
3434+ {ConCache,
3535+ [
3636+ name: @cache,
3737+ ttl_check_interval: ttl_check_interval,
3838+ # No global TTL - each entry sets its own based on token expiry.
3939+ global_ttl: :infinity
4040+ ]}
4141+ ]
4242+4343+ Supervisor.init(children, strategy: :one_for_one)
4444+ end
4545+4646+ @impl Atex.ServiceAuth.JTICache
4747+ @spec put(String.t(), integer()) :: :ok | :seen
4848+ def put(jti, expires_at) do
4949+ now_unix = System.os_time(:second)
5050+ remaining_ms = max((expires_at - now_unix) * 1_000, 0)
5151+5252+ result =
5353+ ConCache.insert_new(@cache, jti, %ConCache.Item{
5454+ value: true,
5555+ ttl: remaining_ms
5656+ })
5757+5858+ case result do
5959+ :ok -> :ok
6060+ {:error, :already_exists} -> :seen
6161+ end
6262+ end
6363+6464+ @impl Atex.ServiceAuth.JTICache
6565+ @spec get(String.t()) :: :ok | :seen
6666+ def get(jti) do
6767+ case ConCache.get(@cache, jti) do
6868+ nil -> :ok
6969+ _ -> :seen
7070+ end
7171+ end
7272+end
+73
test/atex/crypto_test.exs
···1212 # secp256k1 compressed public key as multikey
1313 @k256_multikey "zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc"
14141515+ # Spec example legacy K-256 key (uncompressed, no multicodec) from
1616+ # https://atproto.com/specs/did#legacy-representation
1717+ @legacy_k256_multibase "zQYEBzXeuTM9UR3rfvNag6L3RNAs5pQZyYPsomTsgQhsxLdEgCrPTLgFna8yqCnxPpNT7DBk6Ym3dgPKNu86vt9GR"
1818+ # The same key in the current Multikey (compressed) format
1919+ @multikey_k256_same "zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF"
2020+1521 # ---------------------------------------------------------------------------
1622 # decode_did_key/1
1723 # ---------------------------------------------------------------------------
···135141 {_, orig_map} = JOSE.JWK.to_map(jwk)
136142 {_, decoded_map} = JOSE.JWK.to_map(jwk2)
137143 assert orig_map == decoded_map
144144+ end
145145+ end
146146+147147+ # ---------------------------------------------------------------------------
148148+ # decode_legacy_multibase/2
149149+ # ---------------------------------------------------------------------------
150150+151151+ describe "decode_legacy_multibase/2" do
152152+ test "decodes a legacy K-256 uncompressed multibase into a JOSE JWK" do
153153+ assert {:ok, jwk} =
154154+ Crypto.decode_legacy_multibase(
155155+ "EcdsaSecp256k1VerificationKey2019",
156156+ @legacy_k256_multibase
157157+ )
158158+159159+ assert %JOSE.JWK{} = jwk
160160+ {_, map} = JOSE.JWK.to_map(jwk)
161161+ assert map["kty"] == "EC"
162162+ assert map["crv"] == "secp256k1"
163163+ end
164164+165165+ test "legacy and Multikey encoding of the same K-256 key produce equal JWK coordinates" do
166166+ {:ok, jwk_legacy} =
167167+ Crypto.decode_legacy_multibase(
168168+ "EcdsaSecp256k1VerificationKey2019",
169169+ @legacy_k256_multibase
170170+ )
171171+172172+ {:ok, jwk_current} = Crypto.decode_did_key(@multikey_k256_same)
173173+174174+ {_, legacy_map} = JOSE.JWK.to_map(jwk_legacy)
175175+ {_, current_map} = JOSE.JWK.to_map(jwk_current)
176176+ assert legacy_map["x"] == current_map["x"]
177177+ assert legacy_map["y"] == current_map["y"]
178178+ end
179179+180180+ test "decodes a generated P-256 key from its uncompressed form" do
181181+ priv = JOSE.JWK.generate_key({:ec, "P-256"})
182182+ {_, map} = priv |> JOSE.JWK.to_public() |> JOSE.JWK.to_map()
183183+ x = Base.url_decode64!(map["x"], padding: false)
184184+ y = Base.url_decode64!(map["y"], padding: false)
185185+ legacy_mb = Multiformats.Multibase.encode(<<0x04>> <> x <> y, :base58btc)
186186+187187+ assert {:ok, jwk} =
188188+ Crypto.decode_legacy_multibase("EcdsaSecp256r1VerificationKey2019", legacy_mb)
189189+190190+ {_, decoded_map} = JOSE.JWK.to_map(jwk)
191191+ assert decoded_map["x"] == map["x"]
192192+ assert decoded_map["y"] == map["y"]
193193+ end
194194+195195+ test "returns {:error, :unsupported_curve} for an unknown type" do
196196+ assert {:error, :unsupported_curve} =
197197+ Crypto.decode_legacy_multibase("UnknownType", @legacy_k256_multibase)
198198+ end
199199+200200+ test "returns {:error, :invalid_multikey} for a bad multibase string" do
201201+ assert {:error, :invalid_multikey} =
202202+ Crypto.decode_legacy_multibase("EcdsaSecp256k1VerificationKey2019", "not-base58")
203203+ end
204204+205205+ test "returns {:error, :invalid_point} when bytes are not a valid uncompressed point" do
206206+ bad_bytes = <<0x04>> <> :crypto.strong_rand_bytes(10)
207207+ bad_mb = Multiformats.Multibase.encode(bad_bytes, :base58btc)
208208+209209+ assert {:error, :invalid_point} =
210210+ Crypto.decode_legacy_multibase("EcdsaSecp256k1VerificationKey2019", bad_mb)
138211 end
139212 end
140213