Skip to content

Commit

Permalink
Merge pull request #3920 from rabbitmq/mergify/bp/v3.9.x/pr-3887
Browse files Browse the repository at this point in the history
OAuth 2 plugin improvements (backport #3887)
  • Loading branch information
michaelklishin committed Dec 20, 2021
2 parents d25704b + d4c1226 commit 841cdfd
Show file tree
Hide file tree
Showing 9 changed files with 244 additions and 26 deletions.
40 changes: 40 additions & 0 deletions deps/rabbitmq_auth_backend_oauth2/README.md
Expand Up @@ -139,6 +139,46 @@ In that case, the configuration will look like this:

NOTE: `jwks_url` takes precedence over `signing_keys` if both are provided.

### Variables Configurable in rabbitmq.conf

| Key | Documentation
|------------------------------------------|-----------
| `auth_oauth2.resource_server_id` | [The Resource Server ID](#resource-server-id-and-scope-prefixes)
| `auth_oauth2.additional_scopes_key` | Configure the plugin to also look in other fields (maps to `additional_rabbitmq_scopes` in the old format).
| `auth_oauth2.default_key` | ID of the default signing key.
| `auth_oauth2.signing_keys` | Paths to signing key files.
| `auth_oauth2.jwks_url` | The URL of key server. According to the [JWT Specification](https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.2) key server URL must be https.
| `auth_oauth2.https.cacertfile` | Path to a file containing PEM-encoded CA certificates. The CA certificates are used during key server [peer verification](https://rabbitmq.com/ssl.html#peer-verification).
| `auth_oauth2.https.depth` | The maximum number of non-self-issued intermediate certificates that may follow the peer certificate in a valid [certification path](https://rabbitmq.com/ssl.html#peer-verification-depth). Default is 10.
| `auth_oauth2.https.peer_verification` | Should [peer verification](https://rabbitmq.com/ssl.html#peer-verification) be enabled. Available values: `verify_none`, `verify_peer`. Default is `verify_none`. It is recommended to configure `verify_peer`. Peer verification requires a certain amount of setup and is more secure.
| `auth_oauth2.https.fail_if_no_peer_cert` | Used together with `auth_oauth2.https.peer_verification = verify_peer`. When set to `true`, TLS connection will be rejected if client fails to provide a certificate. Default is `false`.
| `auth_oauth2.https.hostname_verification`| Enable wildcard-aware hostname verification for key server. Available values: `wildcard`, `none`. Default is `none`.
| `auth_oauth2.algorithms` | Restrict [the usable algorithms](https://github.com/potatosalad/erlang-jose#algorithm-support).

For example:

Configure with key files
```
auth_oauth2.resource_server_id = new_resource_server_id
auth_oauth2.additional_scopes_key = my_custom_scope_key
auth_oauth2.default_key = id1
auth_oauth2.signing_keys.id1 = test/config_schema_SUITE_data/certs/key.pem
auth_oauth2.signing_keys.id2 = test/config_schema_SUITE_data/certs/cert.pem
auth_oauth2.algorithms.1 = HS256
auth_oauth2.algorithms.2 = RS256
```
Configure with key server
```
auth_oauth2.resource_server_id = new_resource_server_id
auth_oauth2.jwks_url = https://my-jwt-issuer/jwks.json
auth_oauth2.https.cacertfile = test/config_schema_SUITE_data/certs/cacert.pem
auth_oauth2.https.peer_verification = verify_peer
auth_oauth2.https.depth = 5
auth_oauth2.https.fail_if_no_peer_cert = true
auth_oauth2.https.hostname_verification = wildcard
auth_oauth2.algorithms.1 = HS256
auth_oauth2.algorithms.2 = RS256
```
### Resource Server ID and Scope Prefixes

OAuth 2.0 (and thus UAA-provided) tokens use scopes to communicate what set of permissions particular
Expand Down
Expand Up @@ -77,3 +77,52 @@
end, Settings),
maps:from_list(SigningKeys)
end}.

{mapping,
"auth_oauth2.jwks_url",
"rabbitmq_auth_backend_oauth2.key_config.jwks_url",
[{datatype, string}, {validators, ["uri", "https_uri"]}]}.

{mapping,
"auth_oauth2.https.peer_verification",
"rabbitmq_auth_backend_oauth2.key_config.peer_verification",
[{datatype, {enum, [verify_peer, verify_none]}}]}.

{mapping,
"auth_oauth2.https.cacertfile",
"rabbitmq_auth_backend_oauth2.key_config.cacertfile",
[{datatype, file}, {validators, ["file_accessible"]}]}.

{mapping,
"auth_oauth2.https.depth",
"rabbitmq_auth_backend_oauth2.key_config.depth",
[{datatype, integer}]}.

{mapping,
"auth_oauth2.https.hostname_verification",
"rabbitmq_auth_backend_oauth2.key_config.hostname_verification",
[{datatype, {enum, [wildcard, none]}}]}.

{mapping,
"auth_oauth2.https.crl_check",
"rabbitmq_auth_backend_oauth2.key_config.crl_check",
[{datatype, {enum, [true, false, peer, best_effort]}}]}.

{mapping,
"auth_oauth2.https.fail_if_no_peer_cert",
"rabbitmq_auth_backend_oauth2.key_config.fail_if_no_peer_cert",
[{datatype, {enum, [true, false]}}]}.

{validator, "https_uri", "According to the JWT Specification, Key Server URL must be https.",
fun(Uri) -> string:nth_lexeme(Uri, 1, "://") == "https" end}.

{mapping,
"auth_oauth2.algorithms.$algorithm",
"rabbitmq_auth_backend_oauth2.key_config.algorithms",
[{datatype, string}]}.

{translation, "rabbitmq_auth_backend_oauth2.key_config.algorithms",
fun(Conf) ->
Settings = cuttlefish_variable:filter_by_prefix("auth_oauth2.algorithms", Conf),
[list_to_binary(V) || {_, V} <- Settings]
end}.
27 changes: 27 additions & 0 deletions deps/rabbitmq_auth_backend_oauth2/src/uaa_jwks.erl
@@ -0,0 +1,27 @@
-module(uaa_jwks).
-export([get/1]).

-spec get(string() | binary()) -> {ok, term()} | {error, term()}.
get(JwksUrl) ->
httpc:request(get, {JwksUrl, []}, [{ssl, ssl_options()}, {timeout, 60000}], []).

-spec ssl_options() -> list().
ssl_options() ->
UaaEnv = application:get_env(rabbitmq_auth_backend_oauth2, key_config, []),
PeerVerification = proplists:get_value(peer_verification, UaaEnv, verify_none),
CaCertFile = proplists:get_value(cacertfile, UaaEnv),
Depth = proplists:get_value(depth, UaaEnv, 10),
FailIfNoPeerCert = proplists:get_value(fail_if_no_peer_cert, UaaEnv, false),
CrlCheck = proplists:get_value(crl_check, UaaEnv, false),
SslOpts0 = [{verify, PeerVerification},
{cacertfile, CaCertFile},
{depth, Depth},
{fail_if_no_peer_cert, FailIfNoPeerCert},
{crl_check, CrlCheck},
{crl_cache, {ssl_crl_cache, {internal, [{http, 10000}]}}}],
case proplists:get_value(hostname_verification, UaaEnv, none) of
wildcard ->
[{customize_hostname_check, [{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]} | SslOpts0];
none ->
SslOpts0
end.
2 changes: 1 addition & 1 deletion deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt.erl
Expand Up @@ -58,7 +58,7 @@ update_jwks_signing_keys() ->
undefined ->
{error, no_jwks_url};
JwksUrl ->
case httpc:request(JwksUrl) of
case uaa_jwks:get(JwksUrl) of
{ok, {_, _, JwksBody}} ->
KeyList = maps:get(<<"keys">>, jose:decode(erlang:iolist_to_binary(JwksBody)), []),
Keys = maps:from_list(lists:map(fun(Key) -> {maps:get(<<"kid">>, Key, undefined), {json, Key}} end, KeyList)),
Expand Down
10 changes: 9 additions & 1 deletion deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt_jwt.erl
Expand Up @@ -24,7 +24,15 @@ decode(Token) ->
end.

decode_and_verify(Jwk, Token) ->
case jose_jwt:verify(Jwk, Token) of
UaaEnv = application:get_env(rabbitmq_auth_backend_oauth2, key_config, []),
Verify =
case proplists:get_value(algorithms, UaaEnv) of
undefined ->
jose_jwt:verify(Jwk, Token);
Algs ->
jose_jwt:verify_strict(Jwk, Algs, Token)
end,
case Verify of
{true, #jose_jwt{fields = Fields}, _} -> {true, Fields};
{false, #jose_jwt{fields = Fields}, _} -> {false, Fields}
end.
Expand Down
Expand Up @@ -4,7 +4,16 @@
auth_oauth2.additional_scopes_key = my_custom_scope_key
auth_oauth2.default_key = id1
auth_oauth2.signing_keys.id1 = test/config_schema_SUITE_data/certs/key.pem
auth_oauth2.signing_keys.id2 = test/config_schema_SUITE_data/certs/cert.pem",
auth_oauth2.signing_keys.id2 = test/config_schema_SUITE_data/certs/cert.pem
auth_oauth2.jwks_url = https://my-jwt-issuer/jwks.json
auth_oauth2.https.cacertfile = test/config_schema_SUITE_data/certs/cacert.pem
auth_oauth2.https.peer_verification = verify_none
auth_oauth2.https.depth = 5
auth_oauth2.https.fail_if_no_peer_cert = false
auth_oauth2.https.hostname_verification = wildcard
auth_oauth2.https.crl_check = true
auth_oauth2.algorithms.1 = HS256
auth_oauth2.algorithms.2 = RS256",
[
{rabbitmq_auth_backend_oauth2, [
{resource_server_id,<<"new_resource_server_id">>},
Expand All @@ -16,7 +25,15 @@
<<"id1">> => {pem, <<"I'm not a certificate">>},
<<"id2">> => {pem, <<"I'm not a certificate">>}
}
}
},
{jwks_url, "https://my-jwt-issuer/jwks.json"},
{cacertfile, "test/config_schema_SUITE_data/certs/cacert.pem"},
{peer_verification, verify_none},
{depth, 5},
{fail_if_no_peer_cert, false},
{hostname_verification, wildcard},
{crl_check, true},
{algorithms, [<<"HS256">>, <<"RS256">>]}
]
}
]}
Expand Down
109 changes: 92 additions & 17 deletions deps/rabbitmq_auth_backend_oauth2/test/jwks_SUITE.erl
Expand Up @@ -21,7 +21,9 @@
all() ->
[
{group, happy_path},
{group, unhappy_path}
{group, unhappy_path},
{group, unvalidated_jwks_server},
{group, no_peer_verification}
].

groups() ->
Expand All @@ -34,16 +36,20 @@ groups() ->
test_successful_connection_with_complex_claim_as_a_list,
test_successful_connection_with_complex_claim_as_a_binary,
test_successful_connection_with_keycloak_token,
test_successful_connection_with_algorithm_restriction,
test_successful_token_refresh
]},
{unhappy_path, [], [
test_failed_connection_with_expired_token,
test_failed_connection_with_a_non_token,
test_failed_connection_with_a_token_with_insufficient_vhost_permission,
test_failed_connection_with_a_token_with_insufficient_resource_permission,
test_failed_connection_with_algorithm_restriction,
test_failed_token_refresh_case1,
test_failed_token_refresh_case2
]}
]},
{unvalidated_jwks_server, [], [test_failed_connection_with_unvalidated_jwks_server]},
{no_peer_verification, [], [{group, happy_path}, {group, unhappy_path}]}
].

%%
Expand All @@ -69,23 +75,35 @@ end_per_suite(Config) ->
fun stop_jwks_server/1
] ++ rabbit_ct_broker_helpers:teardown_steps()).

init_per_group(no_peer_verification, Config) ->
add_vhosts(Config),
KeyConfig = rabbit_ct_helpers:set_config(?config(key_config, Config), [{jwks_url, ?config(non_strict_jwks_url, Config)}, {peer_verification, verify_none}]),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, set_env, [rabbitmq_auth_backend_oauth2, key_config, KeyConfig]),
rabbit_ct_helpers:set_config(Config, {key_config, KeyConfig});

init_per_group(_Group, Config) ->
%% The broker is managed by {init,end}_per_testcase().
lists:foreach(fun(Value) ->
rabbit_ct_broker_helpers:add_vhost(Config, Value)
end,
[<<"vhost1">>, <<"vhost2">>, <<"vhost3">>, <<"vhost4">>]),
add_vhosts(Config),
Config.

end_per_group(no_peer_verification, Config) ->
delete_vhosts(Config),
KeyConfig = rabbit_ct_helpers:set_config(?config(key_config, Config), [{jwks_url, ?config(strict_jwks_url, Config)}, {peer_verification, verify_peer}]),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, set_env, [rabbitmq_auth_backend_oauth2, key_config, KeyConfig]),
rabbit_ct_helpers:set_config(Config, {key_config, KeyConfig});

end_per_group(_Group, Config) ->
%% The broker is managed by {init,end}_per_testcase().
lists:foreach(fun(Value) ->
rabbit_ct_broker_helpers:delete_vhost(Config, Value)
end,
[<<"vhost1">>, <<"vhost2">>, <<"vhost3">>, <<"vhost4">>]),
delete_vhosts(Config),
Config.

add_vhosts(Config) ->
%% The broker is managed by {init,end}_per_testcase().
lists:foreach(fun(Value) -> rabbit_ct_broker_helpers:add_vhost(Config, Value) end,
[<<"vhost1">>, <<"vhost2">>, <<"vhost3">>, <<"vhost4">>]).

delete_vhosts(Config) ->
%% The broker is managed by {init,end}_per_testcase().
lists:foreach(fun(Value) -> rabbit_ct_broker_helpers:delete_vhost(Config, Value) end,
[<<"vhost1">>, <<"vhost2">>, <<"vhost3">>, <<"vhost4">>]).

init_per_testcase(Testcase, Config) when Testcase =:= test_successful_connection_with_a_full_permission_token_and_explicitly_configured_vhost orelse
Testcase =:= test_successful_token_refresh ->
Expand All @@ -107,6 +125,24 @@ init_per_testcase(Testcase, Config) when Testcase =:= test_successful_connection
rabbit_ct_helpers:testcase_started(Config, Testcase),
Config;

init_per_testcase(Testcase, Config) when Testcase =:= test_successful_connection_with_algorithm_restriction ->
KeyConfig = ?config(key_config, Config),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, set_env, [rabbitmq_auth_backend_oauth2, key_config, [{algorithms, [<<"HS256">>]} | KeyConfig]]),
rabbit_ct_helpers:testcase_started(Config, Testcase),
Config;

init_per_testcase(Testcase, Config) when Testcase =:= test_failed_connection_with_algorithm_restriction ->
KeyConfig = ?config(key_config, Config),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, set_env, [rabbitmq_auth_backend_oauth2, key_config, [{algorithms, [<<"RS256">>]} | KeyConfig]]),
rabbit_ct_helpers:testcase_started(Config, Testcase),
Config;

init_per_testcase(Testcase, Config) when Testcase =:= test_failed_connection_with_unvalidated_jwks_server ->
KeyConfig = rabbit_ct_helpers:set_config(?config(key_config, Config), {jwks_url, ?config(non_strict_jwks_url, Config)}),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, set_env, [rabbitmq_auth_backend_oauth2, key_config, KeyConfig]),
rabbit_ct_helpers:testcase_started(Config, Testcase),
Config;

init_per_testcase(Testcase, Config) ->
rabbit_ct_helpers:testcase_started(Config, Testcase),
Config.
Expand All @@ -126,6 +162,14 @@ end_per_testcase(Testcase, Config) when Testcase =:= test_successful_connection_
rabbit_ct_helpers:testcase_started(Config, Testcase),
Config;

end_per_testcase(Testcase, Config) when Testcase =:= test_successful_connection_with_algorithm_restriction orelse
Testcase =:= test_failed_connection_with_algorithm_restriction orelse
Testcase =:= test_failed_connection_with_unvalidated_jwks_server ->
rabbit_ct_broker_helpers:delete_vhost(Config, <<"vhost1">>),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, set_env, [rabbitmq_auth_backend_oauth2, key_config, ?config(key_config, Config)]),
rabbit_ct_helpers:testcase_finished(Config, Testcase),
Config;

end_per_testcase(Testcase, Config) ->
rabbit_ct_broker_helpers:delete_vhost(Config, <<"vhost1">>),
rabbit_ct_helpers:testcase_finished(Config, Testcase),
Expand All @@ -143,13 +187,27 @@ start_jwks_server(Config) ->
%% Assume we don't have more than 100 ports allocated for tests
PortBase = rabbit_ct_broker_helpers:get_node_config(Config, 0, tcp_ports_base),
JwksServerPort = PortBase + 100,

%% Both URLs direct to the same JWKS server
%% The NonStrictJwksUrl identity cannot be validated while StrictJwksUrl identity can be validated
NonStrictJwksUrl = "https://127.0.0.1:" ++ integer_to_list(JwksServerPort) ++ "/jwks",
StrictJwksUrl = "https://localhost:" ++ integer_to_list(JwksServerPort) ++ "/jwks",

ok = application:set_env(jwks_http, keys, [Jwk]),
{ok, _} = application:ensure_all_started(ssl),
{ok, _} = application:ensure_all_started(cowboy),
ok = jwks_http_app:start(JwksServerPort),
KeyConfig = [{jwks_url, "http://127.0.0.1:" ++ integer_to_list(JwksServerPort) ++ "/jwks"}],
CertsDir = ?config(rmq_certsdir, Config),
ok = jwks_http_app:start(JwksServerPort, CertsDir),
KeyConfig = [{jwks_url, StrictJwksUrl},
{peer_verification, verify_peer},
{cacertfile, filename:join([CertsDir, "testca", "cacert.pem"])}],
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, set_env,
[rabbitmq_auth_backend_oauth2, key_config, KeyConfig]),
rabbit_ct_helpers:set_config(Config, {fixture_jwk, Jwk}).
rabbit_ct_helpers:set_config(Config,
[{non_strict_jwks_url, NonStrictJwksUrl},
{strict_jwks_url, StrictJwksUrl},
{key_config, KeyConfig},
{fixture_jwk, Jwk}]).

stop_jwks_server(Config) ->
ok = jwks_http_app:stop(),
Expand Down Expand Up @@ -305,7 +363,7 @@ test_successful_token_refresh(Config) ->
Conn = open_unmanaged_connection(Config, 0, <<"vhost1">>, <<"username">>, Token),
{ok, Ch} = amqp_connection:open_channel(Conn),

{_Algo, Token2} = generate_valid_token(Config, [<<"rabbitmq.configure:vhost1/*">>,
{_Algo2, Token2} = generate_valid_token(Config, [<<"rabbitmq.configure:vhost1/*">>,
<<"rabbitmq.write:vhost1/*">>,
<<"rabbitmq.read:vhost1/*">>]),
?UTIL_MOD:wait_for_token_to_expire(timer:seconds(Duration)),
Expand All @@ -321,6 +379,13 @@ test_successful_token_refresh(Config) ->
amqp_channel:close(Ch2),
close_connection_and_channel(Conn, Ch).

test_successful_connection_with_algorithm_restriction(Config) ->
{_Algo, Token} = rabbit_ct_helpers:get_config(Config, fixture_jwt),
Conn = open_unmanaged_connection(Config, 0, <<"username">>, Token),
{ok, Ch} = amqp_connection:open_channel(Conn),
#'queue.declare_ok'{queue = _} =
amqp_channel:call(Ch, #'queue.declare'{exclusive = true}),
close_connection_and_channel(Conn, Ch).

test_failed_connection_with_expired_token(Config) ->
{_Algo, Token} = generate_expired_token(Config, [<<"rabbitmq.configure:vhost1/*">>,
Expand Down Expand Up @@ -359,7 +424,7 @@ test_failed_token_refresh_case1(Config) ->
#'queue.declare_ok'{queue = _} =
amqp_channel:call(Ch, #'queue.declare'{exclusive = true}),

{_Algo, Token2} = generate_expired_token(Config, [<<"rabbitmq.configure:vhost4/*">>,
{_Algo2, Token2} = generate_expired_token(Config, [<<"rabbitmq.configure:vhost4/*">>,
<<"rabbitmq.write:vhost4/*">>,
<<"rabbitmq.read:vhost4/*">>]),
%% the error is communicated asynchronously via a connection-level error
Expand Down Expand Up @@ -387,3 +452,13 @@ test_failed_token_refresh_case2(Config) ->
amqp_connection:open_channel(Conn)),

close_connection(Conn).

test_failed_connection_with_algorithm_restriction(Config) ->
{_Algo, Token} = rabbit_ct_helpers:get_config(Config, fixture_jwt),
?assertMatch({error, {auth_failure, _}},
open_unmanaged_connection(Config, 0, <<"username">>, Token)).

test_failed_connection_with_unvalidated_jwks_server(Config) ->
{_Algo, Token} = rabbit_ct_helpers:get_config(Config, fixture_jwt),
?assertMatch({error, {auth_failure, _}},
open_unmanaged_connection(Config, 0, <<"username">>, Token)).

0 comments on commit 841cdfd

Please sign in to comment.