diff options
author | Robert Newson <rnewson@apache.org> | 2022-08-30 11:17:44 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-08-30 11:17:44 +0100 |
commit | d4c7273e706d5121794fc55ee2633af7f7f02543 (patch) | |
tree | 6a1f0b982d991d7bd6a7e59b5af448729829f7ae | |
parent | eee3c4fc24a3464d597b1051059cf4fd10fbe4cb (diff) | |
parent | 8af61fbb006cb7cf233e3a33082aca298596458e (diff) | |
download | couchdb-d4c7273e706d5121794fc55ee2633af7f7f02543.tar.gz |
Merge pull request #4041 from apache/draft_allow_nested_json_claim_roles
Allow and evaluate nested json claim roles
-rw-r--r-- | rel/overlay/etc/default.ini | 29 | ||||
-rw-r--r-- | src/couch/src/couch_httpd_auth.erl | 58 | ||||
-rw-r--r-- | 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 |