From cccc0dd396d4dc9e09c9f210921bc1e0d644691f Mon Sep 17 00:00:00 2001 From: Ashlynne Mitchell Date: Wed, 22 Apr 2026 16:47:53 +1000 Subject: [PATCH] fix(service_auth): return {:error, :invalid_jwt} for malformed JWTs instead of raising --- lib/atex/service_auth.ex | 73 +++++++++++++++++++-------------- test/atex/service_auth_test.exs | 15 +++++++ 2 files changed, 58 insertions(+), 30 deletions(-) create mode 100644 test/atex/service_auth_test.exs diff --git a/lib/atex/service_auth.ex b/lib/atex/service_auth.ex index 898bf9b..84d2081 100644 --- a/lib/atex/service_auth.ex +++ b/lib/atex/service_auth.ex @@ -103,36 +103,49 @@ defmodule Atex.ServiceAuth do def validate_jwt(jwt, opts \\ []) do {expected_aud, expected_lxm} = options(opts) - %{ - fields: - %{ - "aud" => target_aud, - "iat" => iat, - "exp" => exp, - "iss" => issuing_did, - "jti" => nonce - } = fields - } = JOSE.JWT.peek(jwt) - - target_lxm = Map.get(fields, "lxm") - - with :ok <- validate_aud(expected_aud, target_aud), - :ok <- validate_lxm(expected_lxm, target_lxm), - :ok <- validate_token_times(iat, exp), - # Resolve JWT's issuer to: a) make sure it's a real identity, b) get - # the signing key from their DID document to verify the token - {:ok, identity} <- Atex.IdentityResolver.resolve(issuing_did), - user_jwk when not is_nil(user_jwk) <- - Atex.DID.Document.get_atproto_signing_key(identity.document), - {true, %JOSE.JWT{} = jwt_struct, _jws} <- JOSE.JWT.verify(user_jwk, jwt), - # Record the nonce atomically after successful verification. insert_new - # is used under the hood so this returns :seen if the jti was already - # consumed, preventing replay attacks. - :ok <- Atex.ServiceAuth.JTICache.put(nonce, exp) do - {:ok, jwt_struct} - else - :seen -> {:error, :replayed_token} - err -> err + peek_result = + try do + peeked = JOSE.JWT.peek(jwt) + {:ok, peeked} + rescue + _ -> {:error, :invalid_jwt} + end + + case peek_result do + {:error, _} = err -> + err + + {:ok, + %{ + fields: + %{ + "aud" => target_aud, + "iat" => iat, + "exp" => exp, + "iss" => issuing_did, + "jti" => nonce + } = fields + }} -> + target_lxm = Map.get(fields, "lxm") + + with :ok <- validate_aud(expected_aud, target_aud), + :ok <- validate_lxm(expected_lxm, target_lxm), + :ok <- validate_token_times(iat, exp), + # Resolve JWT's issuer to: a) make sure it's a real identity, b) get + # the signing key from their DID document to verify the token + {:ok, identity} <- Atex.IdentityResolver.resolve(issuing_did), + user_jwk when not is_nil(user_jwk) <- + Atex.DID.Document.get_atproto_signing_key(identity.document), + {true, %JOSE.JWT{} = jwt_struct, _jws} <- JOSE.JWT.verify(user_jwk, jwt), + # Record the nonce atomically after successful verification. insert_new + # is used under the hood so this returns :seen if the jti was already + # consumed, preventing replay attacks. + :ok <- Atex.ServiceAuth.JTICache.put(nonce, exp) do + {:ok, jwt_struct} + else + :seen -> {:error, :replayed_token} + err -> err + end end end diff --git a/test/atex/service_auth_test.exs b/test/atex/service_auth_test.exs new file mode 100644 index 0000000..37f2531 --- /dev/null +++ b/test/atex/service_auth_test.exs @@ -0,0 +1,15 @@ +defmodule Atex.ServiceAuthTest do + use ExUnit.Case, async: true + + describe "validate_jwt/2" do + test "returns {:error, :invalid_jwt} for a malformed token string" do + assert {:error, :invalid_jwt} = + Atex.ServiceAuth.validate_jwt("not.a.valid.jwt", aud: "did:web:example.com") + end + + test "returns {:error, :invalid_jwt} for an empty string" do + assert {:error, :invalid_jwt} = + Atex.ServiceAuth.validate_jwt("", aud: "did:web:example.com") + end + end +end -- 2.51.2