summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRonny Berndt <ronny@apache.org>2022-05-27 11:29:54 +0200
committerRobert Newson <rnewson@apache.org>2022-08-30 10:40:38 +0100
commit8af61fbb006cb7cf233e3a33082aca298596458e (patch)
tree6a1f0b982d991d7bd6a7e59b5af448729829f7ae
parenteee3c4fc24a3464d597b1051059cf4fd10fbe4cb (diff)
downloadcouchdb-8af61fbb006cb7cf233e3a33082aca298596458e.tar.gz
Allow and evaluate nested json claim roles in JWT token
-rw-r--r--rel/overlay/etc/default.ini29
-rw-r--r--src/couch/src/couch_httpd_auth.erl58
-rw-r--r--test/elixir/test/jwt_roles_claim_test.exs167
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