An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization

feat: inter-service auth validation

ovyerus.com 1ecef443 1efe86c0

verified
+463 -3
+1
CHANGELOG.md
··· 31 31 ### Added 32 32 33 33 - `Atex.PLC` module for interacting with [a did:plc directory API](https://web.plc.directory/). 34 + - `Atex.ServiceAuth` module for validating [inter-service authentication tokens](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 34 35 35 36 ### Fixed 36 37
+3 -2
README.md
··· 1 1 # atex 2 2 3 - An Elxir toolkit for the [AT Protocol](https://atproto.com). 3 + An Elixir toolkit for the [AT Protocol](https://atproto.com). 4 4 5 5 ## Feature map 6 6 ··· 15 15 - [x] XRPC client 16 16 - With integration for generated Lexicon structs! 17 17 - [ ] Repository reading and manipulation (MST & CAR) 18 - - [ ] Service auth 18 + - [x] Service auth 19 19 - [x] PLC client 20 + - [ ] XRPC server router 20 21 21 22 Looking to use a data subscription service like the Firehose, [Jetstream], or [Tap]? Check out [Drinkup]. 22 23
+49
examples/service_auth.ex
··· 1 + defmodule ServiceAuthExample do 2 + require Logger 3 + use Plug.Router 4 + 5 + plug :match 6 + plug :dispatch 7 + 8 + @did_doc JSON.encode!(%{ 9 + "@context" => [ 10 + "https://www.w3.org/ns/did/v1", 11 + "https://w3id.org/security/multikey/v1" 12 + ], 13 + "id" => "did:web:setsuna.prawn-galaxy.ts.net", 14 + "verificationMethod" => [ 15 + %{ 16 + "id" => "did:web:setsuna.prawn-galaxy.ts.net#atproto", 17 + "type" => "Multikey", 18 + "controller" => "did:web:setsuna.prawn-galaxy.ts.net", 19 + "publicKeyMultibase" => "zDnaeRBG9swcjKP6GjjQF7kqxP6JaJaVbvjTjJ1YbXnKWWLna" 20 + } 21 + ], 22 + "service" => [ 23 + %{ 24 + "id" => "atex_test", 25 + "type" => "AtexTest", 26 + "serviceEndpoint" => "https://setsuna.prawn-galaxy.ts.net" 27 + } 28 + ] 29 + }) 30 + 31 + get "/.well-known/did.json" do 32 + Logger.info("got did json") 33 + 34 + conn 35 + |> put_resp_content_type("application/json") 36 + |> send_resp(200, @did_doc) 37 + end 38 + 39 + get "/xrpc/com.ovyerus.example" do 40 + IO.inspect(conn) 41 + 42 + conn 43 + |> send_resp(200, "") 44 + end 45 + 46 + match _ do 47 + send_resp(conn, 404, "Not Found") 48 + end 49 + end
+1
lib/atex/application.ex
··· 8 8 Atex.IdentityResolver.Cache, 9 9 Atex.OAuth.Cache, 10 10 Atex.OAuth.SessionStore, 11 + Atex.ServiceAuth.JTICache, 11 12 {Mutex, name: Atex.SessionMutex} 12 13 ] 13 14
+8
lib/atex/crypto.ex
··· 201 201 _ -> {:error, :sign_failed} 202 202 end 203 203 204 + def generate_p256() do 205 + JOSE.JWK.generate_key({:ec, "P-256"}) 206 + end 207 + 208 + def generate_k256() do 209 + JOSE.JWK.generate_key({:ec, "secp256k1"}) 210 + end 211 + 204 212 # Private helpers 205 213 206 214 @spec strip_did_key_prefix(String.t()) :: String.t()
+1 -1
lib/atex/identity_resolver/identity.ex
··· 12 12 @typedoc """ 13 13 The resolved DID document for an identity. 14 14 """ 15 - @type document() :: Atex.IdentityResolver.DIDDocument.t() 15 + @type document() :: Atex.PLC.DIDDocument.t() 16 16 17 17 typedstruct do 18 18 field :did, did(), enforce: true
+21
lib/atex/plc/did_document.ex
··· 136 136 end 137 137 end 138 138 139 + @spec get_atproto_signing_key(t()) :: JOSE.JWK.t() | nil 140 + def get_atproto_signing_key(%__MODULE__{} = doc) do 141 + doc.verification_method 142 + |> Enum.find(fn 143 + %{id: id} -> String.ends_with?(id, "#atproto") 144 + end) 145 + |> case do 146 + nil -> 147 + nil 148 + 149 + %{public_key_multibase: multibase} -> 150 + {:ok, jwk} = Atex.Crypto.decode_did_key(multibase) 151 + jwk 152 + 153 + # TODO 154 + _ -> 155 + raise ArgumentError, message: "Legacy verification method keys are not yet supported" 156 + # %{public_key_jwk: jwk} -> nil 157 + end 158 + end 159 + 139 160 defp valid_pds_endpoint?(endpoint) do 140 161 case URI.new(endpoint) do 141 162 {:ok, uri} ->
+182
lib/atex/service_auth.ex
··· 1 + defmodule Atex.ServiceAuth do 2 + @moduledoc """ 3 + Validating and working with inter-service authentication tokens. 4 + 5 + Provides functions for validating [ATProto inter-service authentication JWTs](https://atproto.com/specs/xrpc#inter-service-authentication-jwt), 6 + either from a raw token string or directly from an incoming `Plug.Conn`. 7 + 8 + Validation covers: 9 + 10 + - Token timing (`iat` not in the future, `exp` not in the past). 11 + - Audience (`aud`) matching the caller-supplied expected value. 12 + - Optional lexicon method (`lxm`) matching the caller-supplied expected value. 13 + - Issuer DID resolution and signing-key verification via `Atex.IdentityResolver`. 14 + - Replay prevention via `Atex.ServiceAuth.JTICache` - each `jti` nonce may 15 + only be accepted once. 16 + 17 + ## Configuration 18 + 19 + The JTI cache implementation is pluggable. See `Atex.ServiceAuth.JTICache` for 20 + details. 21 + """ 22 + 23 + import Plug.Conn 24 + 25 + @typedoc """ 26 + Options accepted by `validate_conn/2` and `validate_jwt/2`. 27 + 28 + - `:aud` - **required**. The expected audience string. The token's `aud` claim 29 + must equal this value exactly. 30 + - `:lxm` - optional. When provided, the token's `lxm` claim must match. If 31 + the token omits `lxm` the check is skipped; if the token carries `lxm` but 32 + no expected value is configured, validation fails with `:lxm_not_configured`. 33 + """ 34 + @type validate_option() :: {:lxm, String.t()} | {:aud, String.t()} 35 + 36 + @doc """ 37 + Validate a service auth token from a `Plug.Conn` request. 38 + 39 + Extracts the `Authorization: Bearer <jwt>` header and delegates to 40 + `validate_jwt/2`. Returns `{:error, :missing_token}` when the header is 41 + absent or malformed. 42 + 43 + ## Options 44 + 45 + See `t:validate_option/0`. 46 + 47 + ## Examples 48 + 49 + iex> Atex.ServiceAuth.validate_conn(conn, aud: "did:web:my-service.example") 50 + {:ok, %JOSE.JWT{}} 51 + 52 + iex> Atex.ServiceAuth.validate_conn(conn, aud: "did:web:my-service.example", lxm: "app.bsky.feed.getTimeline") 53 + {:error, :lxm_mismatch} 54 + """ 55 + @spec validate_conn(Plug.Conn.t(), list(validate_option())) :: 56 + {:ok, jwt :: JOSE.JWT.t()} | {:error, reason :: atom()} 57 + def validate_conn(conn, opts \\ []) do 58 + case get_req_header(conn, "authorization") do 59 + ["Bearer " <> jwt] -> validate_jwt(jwt, opts) 60 + [_] -> :error 61 + _ -> :error 62 + end 63 + end 64 + 65 + @doc """ 66 + Validate a raw service auth JWT string. 67 + 68 + Performs the full validation pipeline: 69 + 70 + 1. Decodes the JWT payload (without verifying the signature yet) to extract claims. 71 + 2. Validates `:aud` and `:lxm` against the provided options. 72 + 3. Validates token timing (`iat`, `exp`). 73 + 4. Resolves the issuer DID and retrieves the ATProto signing key from their 74 + DID document. 75 + 5. Verifies the JWT signature with the resolved key. 76 + 6. Records the `jti` nonce in `Atex.ServiceAuth.JTICache` - returns 77 + `{:error, :replayed_token}` if it has already been seen. 78 + 79 + ## Options 80 + 81 + See `t:validate_option/0`. 82 + 83 + ## Error reasons 84 + 85 + - `:aud_mismatch` - `aud` claim does not match the expected audience. 86 + - `:lxm_mismatch` - `lxm` claim does not match the expected lexicon method. 87 + - `:lxm_not_configured` - token carries an `lxm` claim but no expected value 88 + was provided via `:lxm` opt. 89 + - `:future_iat` - `iat` is in the future. 90 + - `:expired` - `exp` is in the past. 91 + - `:replayed_token` - `jti` has already been used. 92 + 93 + ## Examples 94 + 95 + iex> Atex.ServiceAuth.validate_jwt(jwt, aud: "did:web:my-service.example") 96 + {:ok, %JOSE.JWT{}} 97 + 98 + iex> Atex.ServiceAuth.validate_jwt(expired_jwt, aud: "did:web:my-service.example") 99 + {:error, :expired} 100 + """ 101 + @spec validate_jwt(String.t(), list(validate_option())) :: 102 + {:ok, jwt :: JOSE.JWT.t()} | {:error, reason :: atom()} 103 + def validate_jwt(jwt, opts \\ []) do 104 + {expected_aud, expected_lxm} = options(opts) 105 + 106 + %{ 107 + fields: 108 + %{ 109 + "aud" => target_aud, 110 + "iat" => iat, 111 + "exp" => exp, 112 + "iss" => issuing_did, 113 + "jti" => nonce 114 + } = fields 115 + } = JOSE.JWT.peek(jwt) 116 + 117 + target_lxm = Map.get(fields, "lxm") 118 + 119 + with :ok <- validate_aud(expected_aud, target_aud), 120 + :ok <- validate_lxm(expected_lxm, target_lxm), 121 + :ok <- validate_token_times(iat, exp), 122 + # Resolve JWT's issuer to: a) make sure it's a real identity, b) get 123 + # the signing key from their DID document to verify the token 124 + {:ok, identity} <- Atex.IdentityResolver.resolve(issuing_did), 125 + user_jwk when not is_nil(user_jwk) <- 126 + Atex.PLC.DIDDocument.get_atproto_signing_key(identity.document), 127 + {true, %JOSE.JWT{} = jwt_struct, _jws} <- JOSE.JWT.verify(user_jwk, jwt), 128 + # Record the nonce atomically after successful verification. insert_new 129 + # is used under the hood so this returns :seen if the jti was already 130 + # consumed, preventing replay attacks. 131 + :ok <- Atex.ServiceAuth.JTICache.put(nonce, exp) do 132 + {:ok, jwt_struct} 133 + else 134 + :seen -> {:error, :replayed_token} 135 + err -> err 136 + end 137 + end 138 + 139 + @spec validate_token_times(integer(), integer()) :: :ok | {:error, reason :: atom()} 140 + defp validate_token_times(iat, exp) do 141 + now = DateTime.utc_now() 142 + 143 + with {:ok, iat} <- DateTime.from_unix(iat), 144 + {:ok, exp} <- DateTime.from_unix(exp) do 145 + cond do 146 + DateTime.before?(now, iat) -> 147 + {:error, :future_iat} 148 + 149 + DateTime.after?(now, exp) -> 150 + {:error, :expired} 151 + 152 + true -> 153 + :ok 154 + end 155 + end 156 + end 157 + 158 + @spec validate_aud(String.t(), String.t()) :: :ok | {:error, reason :: atom()} 159 + defp validate_aud(expected, target) when expected == target, do: :ok 160 + defp validate_aud(_expected, _target), do: {:error, :aud_mismatch} 161 + 162 + @spec validate_lxm(String.t() | nil, String.t() | nil) :: :ok | {:error, reason :: atom()} 163 + defp validate_lxm(expected, target) when expected == target, do: :ok 164 + # `lxm` in JWTs is currently optional so we can do this. 165 + # TODO: should have an option to force requirement (e.g. for security-sensitive operations) 166 + defp validate_lxm(expected, nil) when is_binary(expected), do: :ok 167 + defp validate_lxm(nil, target) when is_binary(target), do: {:error, :lxm_not_configured} 168 + defp validate_lxm(expected, target) when expected != target, do: {:error, :lxm_mismatch} 169 + 170 + @spec options(list(validate_option())) :: {aud :: String.t(), lxm :: String.t() | nil} 171 + defp options(opts) do 172 + opts = Keyword.validate!(opts, aud: nil, lxm: nil) 173 + aud = Keyword.get(opts, :aud) 174 + lxm = Keyword.get(opts, :lxm) 175 + 176 + if !aud do 177 + raise ArgumentError, "`:aud` option is required for service auth validation" 178 + end 179 + 180 + {aud, lxm} 181 + end 182 + end
+52
lib/atex/service_auth/jti_cache.ex
··· 1 + defmodule Atex.ServiceAuth.JTICache do 2 + @moduledoc """ 3 + Behaviour and compile-time dispatch for tracking used `jti` (JWT ID) nonces 4 + from service auth tokens, preventing replay attacks. 5 + 6 + Implementations are responsible for: 7 + 8 + - Storing a `jti` alongside its expiry so that entries can be evicted once 9 + the corresponding token has naturally expired (avoiding unbounded growth). 10 + - Returning `:seen` when a `jti` has already been recorded, and `:ok` when it 11 + is new (and recording it atomically). 12 + 13 + ## Configuration 14 + 15 + The active implementation is resolved at compile time: 16 + 17 + ```elixir 18 + config :atex, :jti_cache, Atex.ServiceAuth.JTICache.ETS 19 + ``` 20 + 21 + Defaults to `Atex.ServiceAuth.JTICache.ETS` when not configured. 22 + """ 23 + 24 + @cache Application.compile_env(:atex, :jti_cache, Atex.ServiceAuth.JTICache.ETS) 25 + 26 + @doc """ 27 + Record a `jti` as seen. The implementation must store it until at least 28 + `expires_at` (a Unix timestamp integer) so that expired tokens cannot be 29 + replayed before the entry is evicted. 30 + 31 + Returns `:ok` if this is the first time the `jti` has been seen, or `:seen` 32 + if it was already present. 33 + """ 34 + @callback put(jti :: String.t(), expires_at :: integer()) :: :ok | :seen 35 + 36 + @doc """ 37 + Check whether a `jti` has already been seen without modifying the cache. 38 + 39 + Returns `:ok` if unseen, `:seen` if already present. 40 + """ 41 + @callback get(jti :: String.t()) :: :ok | :seen 42 + 43 + @doc """ 44 + Get the child specification for starting the cache in a supervision tree. 45 + """ 46 + @callback child_spec(any()) :: Supervisor.child_spec() 47 + 48 + defdelegate put(jti, expires_at), to: @cache 49 + defdelegate get(jti), to: @cache 50 + @doc false 51 + defdelegate child_spec(opts), to: @cache 52 + end
+72
lib/atex/service_auth/jti_cache/ets.ex
··· 1 + defmodule Atex.ServiceAuth.JTICache.ETS do 2 + @moduledoc """ 3 + ConCache-backed implementation of `Atex.ServiceAuth.JTICache`. 4 + 5 + Each `jti` is stored with a per-item TTL derived from the token's own `exp` 6 + claim, so entries are evicted automatically once the corresponding token could 7 + no longer be presented as valid. This keeps memory use proportional to the 8 + number of currently-live tokens rather than growing without bound. 9 + 10 + The TTL check interval defaults to 30 seconds and can be overridden: 11 + 12 + ```elixir 13 + config :atex, Atex.ServiceAuth.JTICache.ETS, ttl_check_interval: :timer.seconds(10) 14 + ``` 15 + """ 16 + 17 + @behaviour Atex.ServiceAuth.JTICache 18 + use Supervisor 19 + 20 + @cache :atex_service_auth_jti_cache 21 + @default_ttl_check_interval :timer.seconds(30) 22 + 23 + def start_link(opts) do 24 + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) 25 + end 26 + 27 + @impl Supervisor 28 + def init(_opts) do 29 + ttl_check_interval = 30 + Application.get_env(:atex, __MODULE__, []) 31 + |> Keyword.get(:ttl_check_interval, @default_ttl_check_interval) 32 + 33 + children = [ 34 + {ConCache, 35 + [ 36 + name: @cache, 37 + ttl_check_interval: ttl_check_interval, 38 + # No global TTL - each entry sets its own based on token expiry. 39 + global_ttl: :infinity 40 + ]} 41 + ] 42 + 43 + Supervisor.init(children, strategy: :one_for_one) 44 + end 45 + 46 + @impl Atex.ServiceAuth.JTICache 47 + @spec put(String.t(), integer()) :: :ok | :seen 48 + def put(jti, expires_at) do 49 + now_unix = System.os_time(:second) 50 + remaining_ms = max((expires_at - now_unix) * 1_000, 0) 51 + 52 + result = 53 + ConCache.insert_new(@cache, jti, %ConCache.Item{ 54 + value: true, 55 + ttl: remaining_ms 56 + }) 57 + 58 + case result do 59 + :ok -> :ok 60 + {:error, :already_exists} -> :seen 61 + end 62 + end 63 + 64 + @impl Atex.ServiceAuth.JTICache 65 + @spec get(String.t()) :: :ok | :seen 66 + def get(jti) do 67 + case ConCache.get(@cache, jti) do 68 + nil -> :ok 69 + _ -> :seen 70 + end 71 + end 72 + end
+73
test/atex/crypto_test.exs
··· 12 12 # secp256k1 compressed public key as multikey 13 13 @k256_multikey "zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc" 14 14 15 + # Spec example legacy K-256 key (uncompressed, no multicodec) from 16 + # https://atproto.com/specs/did#legacy-representation 17 + @legacy_k256_multibase "zQYEBzXeuTM9UR3rfvNag6L3RNAs5pQZyYPsomTsgQhsxLdEgCrPTLgFna8yqCnxPpNT7DBk6Ym3dgPKNu86vt9GR" 18 + # The same key in the current Multikey (compressed) format 19 + @multikey_k256_same "zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" 20 + 15 21 # --------------------------------------------------------------------------- 16 22 # decode_did_key/1 17 23 # --------------------------------------------------------------------------- ··· 135 141 {_, orig_map} = JOSE.JWK.to_map(jwk) 136 142 {_, decoded_map} = JOSE.JWK.to_map(jwk2) 137 143 assert orig_map == decoded_map 144 + end 145 + end 146 + 147 + # --------------------------------------------------------------------------- 148 + # decode_legacy_multibase/2 149 + # --------------------------------------------------------------------------- 150 + 151 + describe "decode_legacy_multibase/2" do 152 + test "decodes a legacy K-256 uncompressed multibase into a JOSE JWK" do 153 + assert {:ok, jwk} = 154 + Crypto.decode_legacy_multibase( 155 + "EcdsaSecp256k1VerificationKey2019", 156 + @legacy_k256_multibase 157 + ) 158 + 159 + assert %JOSE.JWK{} = jwk 160 + {_, map} = JOSE.JWK.to_map(jwk) 161 + assert map["kty"] == "EC" 162 + assert map["crv"] == "secp256k1" 163 + end 164 + 165 + test "legacy and Multikey encoding of the same K-256 key produce equal JWK coordinates" do 166 + {:ok, jwk_legacy} = 167 + Crypto.decode_legacy_multibase( 168 + "EcdsaSecp256k1VerificationKey2019", 169 + @legacy_k256_multibase 170 + ) 171 + 172 + {:ok, jwk_current} = Crypto.decode_did_key(@multikey_k256_same) 173 + 174 + {_, legacy_map} = JOSE.JWK.to_map(jwk_legacy) 175 + {_, current_map} = JOSE.JWK.to_map(jwk_current) 176 + assert legacy_map["x"] == current_map["x"] 177 + assert legacy_map["y"] == current_map["y"] 178 + end 179 + 180 + test "decodes a generated P-256 key from its uncompressed form" do 181 + priv = JOSE.JWK.generate_key({:ec, "P-256"}) 182 + {_, map} = priv |> JOSE.JWK.to_public() |> JOSE.JWK.to_map() 183 + x = Base.url_decode64!(map["x"], padding: false) 184 + y = Base.url_decode64!(map["y"], padding: false) 185 + legacy_mb = Multiformats.Multibase.encode(<<0x04>> <> x <> y, :base58btc) 186 + 187 + assert {:ok, jwk} = 188 + Crypto.decode_legacy_multibase("EcdsaSecp256r1VerificationKey2019", legacy_mb) 189 + 190 + {_, decoded_map} = JOSE.JWK.to_map(jwk) 191 + assert decoded_map["x"] == map["x"] 192 + assert decoded_map["y"] == map["y"] 193 + end 194 + 195 + test "returns {:error, :unsupported_curve} for an unknown type" do 196 + assert {:error, :unsupported_curve} = 197 + Crypto.decode_legacy_multibase("UnknownType", @legacy_k256_multibase) 198 + end 199 + 200 + test "returns {:error, :invalid_multikey} for a bad multibase string" do 201 + assert {:error, :invalid_multikey} = 202 + Crypto.decode_legacy_multibase("EcdsaSecp256k1VerificationKey2019", "not-base58") 203 + end 204 + 205 + test "returns {:error, :invalid_point} when bytes are not a valid uncompressed point" do 206 + bad_bytes = <<0x04>> <> :crypto.strong_rand_bytes(10) 207 + bad_mb = Multiformats.Multibase.encode(bad_bytes, :base58btc) 208 + 209 + assert {:error, :invalid_point} = 210 + Crypto.decode_legacy_multibase("EcdsaSecp256k1VerificationKey2019", bad_mb) 138 211 end 139 212 end 140 213