You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by rn...@apache.org on 2022/09/01 16:05:57 UTC

[couchdb] 28/31: Allow and evaluate nested json claim roles in JWT token

This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch raft_storemodule
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit eea0293f57634c96f29b90a2cab1bdc62236121e
Author: Ronny Berndt <ro...@apache.org>
AuthorDate: Fri May 27 11:29:54 2022 +0200

    Allow and evaluate nested json claim roles in JWT token
---
 rel/overlay/etc/default.ini               |  29 +++++-
 src/couch/src/couch_httpd_auth.erl        |  58 +++++++++--
 test/elixir/test/jwt_roles_claim_test.exs | 167 ++++++++++++++++++++++++++++++
 3 files changed, 241 insertions(+), 13 deletions(-)

diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index 15cd0d4bd..929c08351 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -181,10 +181,31 @@ bind_address = 127.0.0.1
 ; List of claims to validate
 ; can be the name of a claim like "exp" or a tuple if the claim requires
 ; a parameter
-; required_claims = exp, {iss, "IssuerNameHere"}
-; roles_claim_name = https://example.com/roles
-;
-; [jwt_keys]
+;required_claims = exp, {iss, "IssuerNameHere"}
+; roles_claim_name is marked as deprecated. Please use roles_claim_path instead!
+; Values for ``roles_claim_name`` can only be top-level attributes in the JWT
+; token. If ``roles_claim_path`` is set, then ``roles_claim_name`` is ignored!
+;roles_claim_name = my-couchdb-roles
+; roles_claim_path was introduced to overcome disadvantages of ``roles_claim_name``,
+; because it is not possible with ``roles_claim_name`` to map nested role
+; attributes in the JWT token. There are only two characters with a special meaning.
+; These are
+;    - ``.`` for nesting json attributes and
+;    - ``\.`` to skip nesting
+; Example JWT data-payload:
+; {
+;   "my": {
+;       "nested": {
+;           "_couchdb.roles": [
+;               ...
+;           ]
+;       }
+;   }
+; }
+; would result in the following parameter config:
+;roles_claim_path = my.nested._couchdb\.roles
+
+;[jwt_keys]
 ; Configure at least one key here if using the JWT auth handler.
 ; If your JWT tokens do not include a "kid" attribute, use "_default"
 ; as the config key, otherwise use the kid as the config key.
diff --git a/src/couch/src/couch_httpd_auth.erl b/src/couch/src/couch_httpd_auth.erl
index b3c984174..cdb790f57 100644
--- a/src/couch/src/couch_httpd_auth.erl
+++ b/src/couch/src/couch_httpd_auth.erl
@@ -227,6 +227,7 @@ jwt_authentication_handler(Req) ->
             RequiredClaims = get_configured_claims(),
             case jwtf:decode(?l2b(Jwt), [alg | RequiredClaims], fun jwtf_keystore:get/2) of
                 {ok, {Claims}} ->
+                    Roles = get_roles_claim(Claims),
                     case lists:keyfind(<<"sub">>, 1, Claims) of
                         false ->
                             throw({unauthorized, <<"Token missing sub claim.">>});
@@ -234,15 +235,7 @@ jwt_authentication_handler(Req) ->
                             Req#httpd{
                                 user_ctx = #user_ctx{
                                     name = User,
-                                    roles = couch_util:get_value(
-                                        ?l2b(
-                                            config:get(
-                                                "jwt_auth", "roles_claim_name", "_couchdb.roles"
-                                            )
-                                        ),
-                                        Claims,
-                                        []
-                                    )
+                                    roles = Roles
                                 }
                             }
                     end;
@@ -253,6 +246,53 @@ jwt_authentication_handler(Req) ->
             Req
     end.
 
+tokenize_json_path(Path, SliceStart, [], Result) ->
+    Result1 = Result ++ [?l2b(string:slice(Path, SliceStart))],
+    [?l2b(string:replace(X, "\\.", ".", all)) || X <- Result1];
+tokenize_json_path(Path, SliceStart, [[{Pos, _}] | T], Result) ->
+    Slice = string:slice(Path, SliceStart, Pos - SliceStart),
+    NewResult = Result ++ [?l2b(Slice)],
+    tokenize_json_path(Path, Pos + 1, T, NewResult).
+
+tokenize_json_path(Path, SplitPositions) ->
+    tokenize_json_path(Path, 0, SplitPositions, []).
+
+get_roles_claim(Claims) ->
+    RolesClaimPath = config:get(
+        "jwt_auth", "roles_claim_path"
+    ),
+    Result =
+        case RolesClaimPath of
+            undefined ->
+                couch_util:get_value(
+                    ?l2b(
+                        config:get(
+                            "jwt_auth", "roles_claim_name", "_couchdb.roles"
+                        )
+                    ),
+                    Claims,
+                    []
+                );
+            Defined when is_list(Defined) ->
+                % find all "." but no "\."
+                PathRegex = "(?<!\\\\)\\.",
+                MatchPositions =
+                    case re:run(RolesClaimPath, PathRegex, [global]) of
+                        nomatch -> [];
+                        {match, Pos} -> Pos
+                    end,
+                TokenizedJsonPath = tokenize_json_path(RolesClaimPath, MatchPositions),
+                couch_util:get_nested_json_value({Claims}, TokenizedJsonPath)
+        end,
+    case lists:all(fun erlang:is_binary/1, Result) of
+        true ->
+            Result;
+        false ->
+            throw(
+                {bad_request, <<"Malformed token">>}
+            )
+    end.
+
 get_configured_claims() ->
     Claims = config:get("jwt_auth", "required_claims", ""),
     Re = "((?<key1>[a-z]+)|{(?<key2>[a-z]+)\s*,\s*\"(?<val>[^\"]+)\"})",
diff --git a/test/elixir/test/jwt_roles_claim_test.exs b/test/elixir/test/jwt_roles_claim_test.exs
new file mode 100644
index 000000000..cd23a3c25
--- /dev/null
+++ b/test/elixir/test/jwt_roles_claim_test.exs
@@ -0,0 +1,167 @@
+defmodule JwtRolesClaimTest do
+  use CouchTestCase
+
+  @global_server_config [
+    %{
+      :section => "chttpd",
+      :key => "authentication_handlers",
+      :value => [
+                  "{chttpd_auth, jwt_authentication_handler}, ",
+                  "{chttpd_auth, cookie_authentication_handler}, ",
+                  "{chttpd_auth, default_authentication_handler})"
+                ] |> Enum.join
+    },
+    %{
+      :section => "jwt_keys",
+      :key => "hmac:myjwttestkey",
+      :value => ~w(
+        NTNv7j0TuYARvmNMmWXo6fKvM4o6nv/aUi9ryX38ZH+L1bkrnD1ObOQ8JAUmHCBq7
+        Iy7otZcyAagBLHVKvvYaIpmMuxmARQ97jUVG16Jkpkp1wXOPsrF9zwew6TpczyH
+        kHgX5EuLg2MeBuiT/qJACs1J0apruOOJCg/gOtkjB4c=) |> Enum.join()
+    }
+  ]
+
+  test "case: roles_claim_name (undefined) / roles_claim_path (undefined)" do
+    server_config = @global_server_config
+
+    run_on_modified_server(server_config, fn ->
+      test_roles(["_couchdb.roles_1", "_couchdb.roles_2"])
+    end)
+  end
+
+  test "case: roles_claim_name (defined) / roles_claim_path (undefined)" do
+    server_config =
+      [
+        %{
+          :section => "jwt_auth",
+          :key => "roles_claim_name",
+          :value => "my._couchdb.roles"
+        }
+      ] ++ @global_server_config
+
+    run_on_modified_server(server_config, fn ->
+      test_roles(["my._couchdb.roles_1", "my._couchdb.roles_2"])
+    end)
+  end
+
+  test "case: roles_claim_name (undefined) / roles_claim_path (defined)" do
+    server_config =
+      [
+        %{
+          :section => "jwt_auth",
+          :key => "roles_claim_path",
+          :value => "foo.bar\\.zonk.baz\\.buu.baa.baa\\.bee.roles"
+        }
+      ] ++ @global_server_config
+
+    run_on_modified_server(server_config, fn ->
+      test_roles(["my_nested_role_1", "my_nested_role_2"])
+    end)
+  end
+
+  test "case: roles_claim_name (defined) / roles_claim_path (defined)" do
+    server_config =
+      [
+        %{
+          :section => "jwt_auth",
+          :key => "roles_claim_name",
+          :value => "my._couchdb.roles"
+        },
+        %{
+          :section => "jwt_auth",
+          :key => "roles_claim_path",
+          :value => "foo.bar\\.zonk.baz\\.buu.baa.baa\\.bee.roles"
+        }
+      ] ++ @global_server_config
+
+    run_on_modified_server(server_config, fn ->
+      test_roles(["my_nested_role_1", "my_nested_role_2"])
+    end)
+  end
+
+  test "case: roles_claim_path with bad input" do
+    server_config =
+      [
+        %{
+          :section => "jwt_auth",
+          :key => "roles_claim_path",
+          :value => "<<foo.bar\\.zonk.baz\\.buu.baa.baa\\.bee.roles"
+        }
+      ] ++ @global_server_config
+
+    run_on_modified_server(server_config, fn ->
+      test_roles_with_bad_input()
+    end)
+
+    server_config =
+      [
+        %{
+          :section => "jwt_auth",
+          :key => "roles_claim_path",
+          :value => "foo.bar\\.zonk.baz\\.buu.baa.baa\\.bee.roles>>"
+        }
+      ] ++ @global_server_config
+
+    run_on_modified_server(server_config, fn ->
+      test_roles_with_bad_input()
+    end)
+
+    server_config =
+      [
+        %{
+          :section => "jwt_auth",
+          :key => "roles_claim_path",
+          :value => "123456"
+        }
+      ] ++ @global_server_config
+
+    run_on_modified_server(server_config, fn ->
+      test_roles_with_bad_input()
+    end)
+  end
+
+  def test_roles(roles) do
+    token = ~w(
+      eyJ0eXAiOiJKV1QiLCJraWQiOiJteWp3dHRlc3RrZXkiLCJhbGciOiJIUzI1NiJ9.
+      eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRyd
+      WUsImlhdCI6MTY1NTI5NTgxMCwiZXhwIjoxNzU1Mjk5NDEwLCJteSI6eyJuZXN0ZW
+      QiOnsiX2NvdWNoZGIucm9sZXMiOlsibXlfbmVzdGVkX2NvdWNoZGIucm9sZXNfMSI
+      sIm15X25lc3RlZF9jb3VjaGRiLnJvbGVzXzEiXX19LCJfY291Y2hkYi5yb2xlcyI6
+      WyJfY291Y2hkYi5yb2xlc18xIiwiX2NvdWNoZGIucm9sZXNfMiJdLCJteS5fY291Y
+      2hkYi5yb2xlcyI6WyJteS5fY291Y2hkYi5yb2xlc18xIiwibXkuX2NvdWNoZGIucm
+      9sZXNfMiJdLCJmb28iOnsiYmFyLnpvbmsiOnsiYmF6LmJ1dSI6eyJiYWEiOnsiYmF
+      hLmJlZSI6eyJyb2xlcyI6WyJteV9uZXN0ZWRfcm9sZV8xIiwibXlfbmVzdGVkX3Jv
+      bGVfMiJdfX19fX19.F6kQK-FK0z1kP01bTyw-moXfy2klWfubgF7x7Xitd-0) |> Enum.join()
+
+    resp =
+      Couch.get("/_session",
+        headers: [authorization: "Bearer #{token}"]
+      )
+
+    assert resp.body["userCtx"]["name"] == "1234567890"
+    assert resp.body["userCtx"]["roles"] == roles
+    assert resp.body["info"]["authenticated"] == "jwt"
+  end
+
+  def test_roles_with_bad_input() do
+    token = ~w(
+      eyJ0eXAiOiJKV1QiLCJraWQiOiJteWp3dHRlc3RrZXkiLCJhbGciOiJIUzI1NiJ9.
+      eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRyd
+      WUsImlhdCI6MTY1NTI5NTgxMCwiZXhwIjoxNzU1Mjk5NDEwLCJteSI6eyJuZXN0ZW
+      QiOnsiX2NvdWNoZGIucm9sZXMiOlsibXlfbmVzdGVkX2NvdWNoZGIucm9sZXNfMSI
+      sIm15X25lc3RlZF9jb3VjaGRiLnJvbGVzXzEiXX19LCJfY291Y2hkYi5yb2xlcyI6
+      WyJfY291Y2hkYi5yb2xlc18xIiwiX2NvdWNoZGIucm9sZXNfMiJdLCJteS5fY291Y
+      2hkYi5yb2xlcyI6WyJteS5fY291Y2hkYi5yb2xlc18xIiwibXkuX2NvdWNoZGIucm
+      9sZXNfMiJdLCJmb28iOnsiYmFyLnpvbmsiOnsiYmF6LmJ1dSI6eyJiYWEiOnsiYmF
+      hLmJlZSI6eyJyb2xlcyI6WyJteV9uZXN0ZWRfcm9sZV8xIiwibXlfbmVzdGVkX3Jv
+      bGVfMiJdfX19fX19.F6kQK-FK0z1kP01bTyw-moXfy2klWfubgF7x7Xitd-0) |> Enum.join()
+
+    resp =
+      Couch.get("/_session",
+        headers: [authorization: "Bearer #{token}"]
+      )
+
+    assert resp.status_code == 404
+  end
+
+end