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