Merge pull request #4604 from rabbitmq/rabbitmq-server-4588

OAuth 2: support for scope aliases
This commit is contained in:
Michael Klishin 2022-04-23 08:33:07 +04:00 committed by GitHub
commit 2dccccfdb4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 533 additions and 110 deletions

View File

@ -25,12 +25,30 @@
-ifdef(TEST).
-compile(export_all).
-endif.
%%--------------------------------------------------------------------
%%
%% App environment
%%
-type app_env() :: [{atom(), any()}].
-define(APP, rabbitmq_auth_backend_oauth2).
-define(RESOURCE_SERVER_ID, resource_server_id).
%% a term used by the IdentityServer community
-define(COMPLEX_CLAIM, extra_scopes_source).
-define(COMPLEX_CLAIM_APP_ENV_KEY, extra_scopes_source).
%% scope aliases map "role names" to a set of scopes
-define(SCOPE_MAPPINGS_APP_ENV_KEY, scope_aliases).
%%
%% Key JWT fields
%%
-define(AUD_JWT_FIELD, <<"aud">>).
-define(SCOPE_JWT_FIELD, <<"scope">>).
%%
%% API
%%
description() ->
[{name, <<"OAuth 2">>},
@ -143,39 +161,125 @@ validate_token_expiry(#{}) -> ok.
-spec check_token(binary()) -> {ok, map()} | {error, term()}.
check_token(Token) ->
Settings = application:get_all_env(?APP),
case uaa_jwt:decode_and_verify(Token) of
{error, Reason} -> {refused, {error, Reason}};
{true, Payload} -> validate_payload(post_process_payload(Payload));
{true, Payload} -> validate_payload(post_process_payload(Payload, Settings));
{false, _} -> {refused, signature_invalid}
end.
post_process_payload(Payload) when is_map(Payload) ->
post_process_payload(Payload, []).
post_process_payload(Payload, AppEnv) when is_map(Payload) ->
Payload0 = maps:map(fun(K, V) ->
case K of
<<"aud">> when is_binary(V) -> binary:split(V, <<" ">>, [global, trim_all]);
<<"scope">> when is_binary(V) -> binary:split(V, <<" ">>, [global, trim_all]);
?AUD_JWT_FIELD when is_binary(V) -> binary:split(V, <<" ">>, [global, trim_all]);
?SCOPE_JWT_FIELD when is_binary(V) -> binary:split(V, <<" ">>, [global, trim_all]);
_ -> V
end
end,
Payload
),
Payload1 = case does_include_complex_claim_field(Payload0) of
true -> post_process_payload_complex_claim(Payload0);
true -> post_process_payload_with_complex_claim(Payload0);
false -> Payload0
end,
Payload2 = case maps:is_key(<<"authorization">>, Payload1) of
true -> post_process_payload_keycloak(Payload1);
true -> post_process_payload_in_keycloak_format(Payload1);
false -> Payload1
end,
Payload3 = case has_configured_scope_aliases(AppEnv) of
true -> post_process_payload_with_scope_aliases(Payload2, AppEnv);
false -> Payload2
end,
Payload3.
-spec has_configured_scope_aliases(AppEnv :: app_env()) -> boolean().
has_configured_scope_aliases(AppEnv) ->
Map = maps:from_list(AppEnv),
maps:is_key(?SCOPE_MAPPINGS_APP_ENV_KEY, Map).
-spec post_process_payload_with_scope_aliases(Payload :: map(), AppEnv :: app_env()) -> map().
%% This is for those hopeless environments where the token structure is so out of
%% messaging team's control that even the extra scopes field is no longer an option.
%%
%% This assumes that scopes can be random values that do not follow the RabbitMQ
%% convention, or any other convention, in any way. They are just random client role IDs.
%% See rabbitmq/rabbitmq-server#4588 for details.
post_process_payload_with_scope_aliases(Payload, AppEnv) ->
%% try JWT scope field value for alias
Payload1 = post_process_payload_with_scope_alias_in_scope_field(Payload, AppEnv),
%% try the configurable 'extra_scopes_source' field value for alias
Payload2 = post_process_payload_with_scope_alias_in_extra_scopes_source(Payload1, AppEnv),
Payload2.
does_include_complex_claim_field(Payload) when is_map(Payload) ->
maps:is_key(application:get_env(?APP, ?COMPLEX_CLAIM, undefined), Payload).
-spec post_process_payload_with_scope_alias_in_scope_field(Payload :: map(),
AppEnv :: app_env()) -> map().
%% First attempt: use the value in the 'scope' field for alias
post_process_payload_with_scope_alias_in_scope_field(Payload, AppEnv) ->
ScopeMappings = proplists:get_value(?SCOPE_MAPPINGS_APP_ENV_KEY, AppEnv, #{}),
post_process_payload_with_scope_alias_field_named(Payload, ?SCOPE_JWT_FIELD, ScopeMappings).
post_process_payload_complex_claim(Payload) ->
ComplexClaim = maps:get(application:get_env(?APP, ?COMPLEX_CLAIM, undefined), Payload),
-spec post_process_payload_with_scope_alias_in_extra_scopes_source(Payload :: map(),
AppEnv :: app_env()) -> map().
%% Second attempt: use the value in the configurable 'extra scopes source' field for alias
post_process_payload_with_scope_alias_in_extra_scopes_source(Payload, AppEnv) ->
ExtraScopesField = proplists:get_value(?COMPLEX_CLAIM_APP_ENV_KEY, AppEnv, undefined),
case ExtraScopesField of
%% nothing to inject
undefined -> Payload;
_ ->
ScopeMappings = proplists:get_value(?SCOPE_MAPPINGS_APP_ENV_KEY, AppEnv, #{}),
post_process_payload_with_scope_alias_field_named(Payload, ExtraScopesField, ScopeMappings)
end.
-spec post_process_payload_with_scope_alias_field_named(Payload :: map(),
Field :: binary(),
ScopeAliasMapping :: map()) -> map().
post_process_payload_with_scope_alias_field_named(Payload, undefined, _ScopeAliasMapping) ->
Payload;
post_process_payload_with_scope_alias_field_named(Payload, FieldName, ScopeAliasMapping) ->
ExistingScopes = maps:get(?SCOPE_JWT_FIELD, Payload, []),
AdditionalScopes = case FieldName of
undefined -> [];
[] -> [];
_Value ->
ScopeAlias = maps:get(FieldName, Payload, undefined),
case ScopeAlias of
undefined -> [];
[] -> [];
[Value1] ->
rabbit_data_coercion:to_list(maps:get(Value1, ScopeAliasMapping, []));
Value2 when is_binary(Value2) ->
maps:get(Value2, ScopeAliasMapping, []);
Value3 when is_list(Value3) ->
maps:get(list_to_binary(Value3), ScopeAliasMapping, [])
end
end,
case AdditionalScopes of
[] -> Payload;
List when is_list(List) ->
maps:put(?SCOPE_JWT_FIELD, AdditionalScopes ++ ExistingScopes, Payload);
Bin when is_binary(Bin) ->
maps:put(?SCOPE_JWT_FIELD, [Bin | ExistingScopes], Payload)
end.
-spec does_include_complex_claim_field(Payload :: map()) -> boolean().
does_include_complex_claim_field(Payload) when is_map(Payload) ->
maps:is_key(application:get_env(?APP, ?COMPLEX_CLAIM_APP_ENV_KEY, undefined), Payload).
-spec post_process_payload_with_complex_claim(Payload :: map()) -> map().
post_process_payload_with_complex_claim(Payload) ->
ComplexClaim = maps:get(application:get_env(?APP, ?COMPLEX_CLAIM_APP_ENV_KEY, undefined), Payload),
ResourceServerId = rabbit_data_coercion:to_binary(application:get_env(?APP, ?RESOURCE_SERVER_ID, <<>>)),
AdditionalScopes =
@ -199,18 +303,19 @@ post_process_payload_complex_claim(Payload) ->
case AdditionalScopes of
[] -> Payload;
_ ->
ExistingScopes = maps:get(<<"scope">>, Payload, []),
maps:put(<<"scope">>, AdditionalScopes ++ ExistingScopes, Payload)
ExistingScopes = maps:get(?SCOPE_JWT_FIELD, Payload, []),
maps:put(?SCOPE_JWT_FIELD, AdditionalScopes ++ ExistingScopes, Payload)
end.
-spec post_process_payload_in_keycloak_format(Payload :: map()) -> map().
%% keycloak token format: https://github.com/rabbitmq/rabbitmq-auth-backend-oauth2/issues/36
post_process_payload_keycloak(#{<<"authorization">> := Authorization} = Payload) ->
post_process_payload_in_keycloak_format(#{<<"authorization">> := Authorization} = Payload) ->
AdditionalScopes = case maps:get(<<"permissions">>, Authorization, undefined) of
undefined -> [];
Permissions -> extract_scopes_from_keycloak_permissions([], Permissions)
end,
ExistingScopes = maps:get(<<"scope">>, Payload),
maps:put(<<"scope">>, AdditionalScopes ++ ExistingScopes, Payload).
ExistingScopes = maps:get(?SCOPE_JWT_FIELD, Payload),
maps:put(?SCOPE_JWT_FIELD, AdditionalScopes ++ ExistingScopes, Payload).
extract_scopes_from_keycloak_permissions(Acc, []) ->
Acc;
@ -225,14 +330,14 @@ extract_scopes_from_keycloak_permissions(Acc, [H | T]) when is_map(H) ->
extract_scopes_from_keycloak_permissions(Acc, [_ | T]) ->
extract_scopes_from_keycloak_permissions(Acc, T).
validate_payload(#{<<"scope">> := _Scope, <<"aud">> := _Aud} = DecodedToken) ->
validate_payload(#{?SCOPE_JWT_FIELD := _Scope, ?AUD_JWT_FIELD := _Aud} = DecodedToken) ->
ResourceServerEnv = application:get_env(?APP, ?RESOURCE_SERVER_ID, <<>>),
ResourceServerId = rabbit_data_coercion:to_binary(ResourceServerEnv),
validate_payload(DecodedToken, ResourceServerId).
validate_payload(#{<<"scope">> := Scope, <<"aud">> := Aud} = DecodedToken, ResourceServerId) ->
validate_payload(#{?SCOPE_JWT_FIELD := Scope, ?AUD_JWT_FIELD := Aud} = DecodedToken, ResourceServerId) ->
case check_aud(Aud, ResourceServerId) of
ok -> {ok, DecodedToken#{<<"scope">> => filter_scopes(Scope, ResourceServerId)}};
ok -> {ok, DecodedToken#{?SCOPE_JWT_FIELD => filter_scopes(Scope, ResourceServerId)}};
{error, Err} -> {refused, {invalid_aud, Err}}
end.
@ -254,7 +359,7 @@ check_aud(Aud, ResourceServerId) ->
%%--------------------------------------------------------------------
get_scopes(#{<<"scope">> := Scope}) -> Scope.
get_scopes(#{?SCOPE_JWT_FIELD := Scope}) -> Scope.
-spec token_from_context(map()) -> binary() | undefined.
token_from_context(AuthProps) ->
@ -296,10 +401,12 @@ username_from(ClientProvidedUsername, DecodedToken) ->
Value
end.
-define(TAG_SCOPE_PREFIX, <<"tag:">>).
-spec tags_from(map()) -> list(atom()).
tags_from(DecodedToken) ->
Scopes = maps:get(<<"scope">>, DecodedToken, []),
TagScopes = matching_scopes_without_prefix(Scopes, <<"tag:">>),
Scopes = maps:get(?SCOPE_JWT_FIELD, DecodedToken, []),
TagScopes = matching_scopes_without_prefix(Scopes, ?TAG_SCOPE_PREFIX),
lists:usort(lists:map(fun rabbit_data_coercion:to_atom/1, TagScopes)).
matching_scopes_without_prefix(Scopes, PrefixPattern) ->

View File

@ -70,10 +70,10 @@ expired_token() ->
expired_token_with_scopes(full_permission_scopes()).
expired_token_with_scopes(Scopes) ->
token_with_scopes_and_expiration(Scopes, os:system_time(seconds) - 10).
token_with_scopes_and_expiration(Scopes, seconds_in_the_past(10)).
fixture_token_with_scopes(Scopes) ->
token_with_scopes_and_expiration(Scopes, os:system_time(seconds) + 30).
token_with_scopes_and_expiration(Scopes, default_expiration_moment()).
token_with_scopes_and_expiration(Scopes, Expiration) ->
%% expiration is a timestamp with precision in seconds
@ -97,3 +97,37 @@ fixture_token(ExtraScopes) ->
fixture_token_with_full_permissions() ->
fixture_token_with_scopes(full_permission_scopes()).
token_with_scope_alias_in_scope_field(Alias) ->
%% expiration is a timestamp with precision in seconds
#{<<"exp">> => default_expiration_moment(),
<<"kid">> => <<"token-key">>,
<<"iss">> => <<"unit_test">>,
<<"foo">> => <<"bar">>,
<<"aud">> => [<<"rabbitmq">>],
<<"scope">> => Alias}.
token_with_scope_alias_in_claim_field(Alias, Scopes) ->
%% expiration is a timestamp with precision in seconds
#{<<"exp">> => default_expiration_moment(),
<<"kid">> => <<"token-key">>,
<<"iss">> => <<"unit_test">>,
<<"foo">> => <<"bar">>,
<<"aud">> => [<<"rabbitmq">>],
<<"scope">> => Scopes,
<<"claims">> => Alias}.
seconds_in_the_future() ->
seconds_in_the_future(30).
seconds_in_the_future(N) ->
os:system_time(seconds) + N.
seconds_in_the_past() ->
seconds_in_the_past(10).
seconds_in_the_past(N) ->
os:system_time(seconds) - N.
default_expiration_moment() ->
seconds_in_the_future(30).

View File

@ -20,29 +20,46 @@
all() ->
[
{group, happy_path},
{group, unhappy_path}
{group, basic_happy_path},
{group, basic_unhappy_path},
{group, token_refresh},
{group, extra_scopes_source},
{group, scope_aliases}
].
groups() ->
[
{happy_path, [], [
{basic_happy_path, [], [
test_successful_connection_with_a_full_permission_token_and_all_defaults,
test_successful_connection_with_a_full_permission_token_and_explicitly_configured_vhost,
test_successful_connection_with_simple_strings_for_aud_and_scope,
test_successful_connection_with_complex_claim_as_a_map,
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_token_refresh
]},
{unhappy_path, [], [
{basic_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_a_token_with_insufficient_resource_permission
]},
{token_refresh, [], [
test_failed_token_refresh_case1,
test_failed_token_refresh_case2
]},
{extra_scopes_source, [], [
test_successful_connection_with_complex_claim_as_a_map,
test_successful_connection_with_complex_claim_as_a_list,
test_successful_connection_with_complex_claim_as_a_binary,
test_successful_connection_with_keycloak_token
]},
{scope_aliases, [], [
test_successful_connection_with_with_scope_alias_in_extra_scopes_source,
test_successful_connection_with_scope_alias_in_scope_field_case1,
test_successful_connection_with_scope_alias_in_scope_field_case2,
test_failed_connection_with_with_non_existent_scope_alias_in_extra_scopes_source,
test_failed_connection_with_non_existent_scope_alias_in_scope_field
]}
].
@ -53,6 +70,9 @@ groups() ->
-define(UTIL_MOD, rabbit_auth_backend_oauth2_test_util).
-define(RESOURCE_SERVER_ID, <<"rabbitmq">>).
-define(EXTRA_SCOPES_SOURCE, <<"additional_rabbitmq_scopes">>).
-define(CLAIMS_FIELD, <<"claims">>).
-define(SCOPE_ALIAS_NAME, <<"role-1">>).
init_per_suite(Config) ->
rabbit_ct_helpers:log_environment(),
@ -82,6 +102,9 @@ end_per_group(_Group, Config) ->
[<<"vhost1">>, <<"vhost2">>, <<"vhost3">>, <<"vhost4">>]),
Config.
%%
%% Per-case setup
%%
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 ->
@ -103,23 +126,74 @@ 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_with_scope_alias_in_extra_scopes_source ->
rabbit_ct_broker_helpers:add_vhost(Config, <<"vhost1">>),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, set_env,
[rabbitmq_auth_backend_oauth2, extra_scopes_source, ?CLAIMS_FIELD]),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, set_env,
[rabbitmq_auth_backend_oauth2, scope_aliases, #{
?SCOPE_ALIAS_NAME => [
<<"rabbitmq.configure:vhost1/*">>,
<<"rabbitmq.write:vhost1/*">>,
<<"rabbitmq.read:vhost1/*">>
]}
]),
rabbit_ct_helpers:testcase_started(Config, Testcase),
Config;
init_per_testcase(Testcase, Config) when Testcase =:= test_successful_connection_with_scope_alias_in_scope_field_case1 orelse
Testcase =:= test_successful_connection_with_scope_alias_in_scope_field_case2 ->
rabbit_ct_broker_helpers:add_vhost(Config, <<"vhost2">>),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, set_env,
[rabbitmq_auth_backend_oauth2, scope_aliases, #{
?SCOPE_ALIAS_NAME => [
<<"rabbitmq.configure:vhost2/*">>,
<<"rabbitmq.write:vhost2/*">>,
<<"rabbitmq.read:vhost2/*">>
]}
]),
rabbit_ct_helpers:testcase_started(Config, Testcase),
Config;
init_per_testcase(Testcase, Config) ->
rabbit_ct_helpers:testcase_started(Config, Testcase),
Config.
%%
%% Per-case Teardown
%%
end_per_testcase(Testcase, Config) when Testcase =:= test_failed_token_refresh_case1 orelse
Testcase =:= test_failed_token_refresh_case2 ->
rabbit_ct_broker_helpers:delete_vhost(Config, <<"vhost4">>),
rabbit_ct_helpers:testcase_started(Config, Testcase),
rabbit_ct_helpers:testcase_finished(Config, Testcase),
Config;
end_per_testcase(Testcase, Config) when Testcase =:= test_successful_connection_with_complex_claim_as_a_map orelse
Testcase =:= test_successful_connection_with_complex_claim_as_a_list orelse
Testcase =:= test_successful_connection_with_complex_claim_as_a_binary ->
rabbit_ct_broker_helpers:delete_vhost(Config, <<"vhost1">>),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, set_env,
[rabbitmq_auth_backend_oauth2, extra_scopes_source, undefined]),
rabbit_ct_helpers:testcase_started(Config, Testcase),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, unset_env,
[rabbitmq_auth_backend_oauth2, extra_scopes_source]),
rabbit_ct_helpers:testcase_finished(Config, Testcase),
Config;
end_per_testcase(Testcase, Config) when Testcase =:= test_successful_connection_with_with_scope_alias_in_extra_scopes_source ->
rabbit_ct_broker_helpers:delete_vhost(Config, <<"vhost1">>),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, unset_env,
[rabbitmq_auth_backend_oauth2, scope_aliases]),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, unset_env,
[rabbitmq_auth_backend_oauth2, extra_scopes_source]),
rabbit_ct_helpers:testcase_finished(Config, Testcase),
Config;
end_per_testcase(Testcase, Config) when Testcase =:= test_successful_connection_with_scope_alias_in_scope_field_case1 orelse
Testcase =:= test_successful_connection_with_scope_alias_in_scope_field_case2 ->
rabbit_ct_broker_helpers:delete_vhost(Config, <<"vhost2">>),
ok = rabbit_ct_broker_helpers:rpc(Config, 0, application, unset_env,
[rabbitmq_auth_backend_oauth2, scope_aliases]),
rabbit_ct_helpers:testcase_finished(Config, Testcase),
Config;
end_per_testcase(Testcase, Config) ->
@ -371,3 +445,42 @@ test_failed_token_refresh_case2(Config) ->
amqp_connection:open_channel(Conn)),
close_connection(Conn).
test_successful_connection_with_with_scope_alias_in_extra_scopes_source(Config) ->
{_Algo, Token} = generate_valid_token_with_extra_fields(
Config,
#{<<"claims">> => ?SCOPE_ALIAS_NAME}
),
Conn = open_unmanaged_connection(Config, 0, <<"vhost1">>, <<"username">>, Token),
{ok, Ch} = amqp_connection:open_channel(Conn),
#'queue.declare_ok'{} =
amqp_channel:call(Ch, #'queue.declare'{queue = <<"one">>, exclusive = true}),
close_connection_and_channel(Conn, Ch).
test_successful_connection_with_scope_alias_in_scope_field_case1(Config) ->
test_successful_connection_with_scope_alias_in_scope_field_case(Config, ?SCOPE_ALIAS_NAME).
test_successful_connection_with_scope_alias_in_scope_field_case2(Config) ->
test_successful_connection_with_scope_alias_in_scope_field_case(Config, [?SCOPE_ALIAS_NAME]).
test_successful_connection_with_scope_alias_in_scope_field_case(Config, Scopes) ->
{_Algo, Token} = generate_valid_token(Config, Scopes),
Conn = open_unmanaged_connection(Config, 0, <<"vhost2">>, <<"username">>, Token),
{ok, Ch} = amqp_connection:open_channel(Conn),
#'queue.declare_ok'{} =
amqp_channel:call(Ch, #'queue.declare'{queue = <<"one">>, exclusive = true}),
close_connection_and_channel(Conn, Ch).
test_failed_connection_with_with_non_existent_scope_alias_in_extra_scopes_source(Config) ->
{_Algo, Token} = generate_valid_token_with_extra_fields(
Config,
#{<<"claims">> => <<"non-existent alias 24823478374">>}
),
?assertMatch({error, not_allowed},
open_unmanaged_connection(Config, 0, <<"vhost1">>, <<"username">>, Token)).
test_failed_connection_with_non_existent_scope_alias_in_scope_field(Config) ->
{_Algo, Token} = generate_valid_token(Config, <<"non-existent alias a8798s7doaisd79">>),
?assertMatch({error, not_allowed},
open_unmanaged_connection(Config, 0, <<"vhost2">>, <<"username">>, Token)).

View File

@ -29,7 +29,11 @@ all() ->
test_incorrect_kid,
test_post_process_token_payload,
test_post_process_token_payload_keycloak,
test_post_process_token_payload_complex_claims
test_post_process_token_payload_complex_claims,
test_successful_access_with_a_token_that_uses_scope_alias_in_scope_field,
test_unsuccessful_access_with_a_token_that_uses_missing_scope_alias_in_scope_field,
test_successful_access_with_a_token_that_uses_scope_alias_in_extra_scope_source_field,
test_unsuccessful_access_with_a_token_that_uses_missing_scope_alias_in_extra_scope_source_field
].
init_per_suite(Config) ->
@ -235,6 +239,8 @@ test_successful_access_with_a_token(_) ->
UaaEnv = [{signing_keys, #{<<"token-key">> => {map, Jwk}}}],
application:set_env(rabbitmq_auth_backend_oauth2, key_config, UaaEnv),
application:set_env(rabbitmq_auth_backend_oauth2, resource_server_id, <<"rabbitmq">>),
VHost = <<"vhost">>,
Username = <<"username">>,
Token = ?UTIL_MOD:sign_token_hs(?UTIL_MOD:fixture_token(), Jwk),
@ -244,36 +250,12 @@ test_successful_access_with_a_token(_) ->
rabbit_auth_backend_oauth2:user_login_authentication(Username, #{password => Token}),
?assertEqual(true, rabbit_auth_backend_oauth2:check_vhost_access(User, <<"vhost">>, none)),
?assertEqual(true, rabbit_auth_backend_oauth2:check_resource_access(
User,
#resource{virtual_host = <<"vhost">>,
kind = queue,
name = <<"foo">>},
configure,
#{})),
?assertEqual(true, rabbit_auth_backend_oauth2:check_resource_access(
User,
#resource{virtual_host = <<"vhost">>,
kind = exchange,
name = <<"foo">>},
write,
#{})),
assert_resource_access_granted(User, VHost, <<"foo">>, configure),
assert_resource_access_granted(User, VHost, <<"foo">>, write),
assert_resource_access_granted(User, VHost, <<"bar">>, read),
assert_resource_access_granted(User, VHost, custom, <<"bar">>, read),
?assertEqual(true, rabbit_auth_backend_oauth2:check_resource_access(
User,
#resource{virtual_host = <<"vhost">>,
kind = custom,
name = <<"bar">>},
read,
#{})),
?assertEqual(true, rabbit_auth_backend_oauth2:check_topic_access(
User,
#resource{virtual_host = <<"vhost">>,
kind = topic,
name = <<"bar">>},
read,
#{routing_key => <<"#/foo">>})).
assert_topic_access_granted(User, VHost, <<"bar">>, read, #{routing_key => <<"#/foo">>}).
test_successful_access_with_a_token_that_has_tag_scopes(_) ->
Jwk = ?UTIL_MOD:fixture_jwk(),
@ -287,6 +269,155 @@ test_successful_access_with_a_token_that_has_tag_scopes(_) ->
{ok, #auth_user{username = Username, tags = [management, policymaker]}} =
rabbit_auth_backend_oauth2:user_login_authentication(Username, [{password, Token}]).
test_successful_access_with_a_token_that_uses_scope_alias_in_scope_field(_) ->
Jwk = ?UTIL_MOD:fixture_jwk(),
UaaEnv = [{signing_keys, #{<<"token-key">> => {map, Jwk}}}],
application:set_env(rabbitmq_auth_backend_oauth2, key_config, UaaEnv),
application:set_env(rabbitmq_auth_backend_oauth2, resource_server_id, <<"rabbitmq">>),
Alias = <<"client-alias-1">>,
application:set_env(rabbitmq_auth_backend_oauth2, scope_aliases, #{
Alias => [
<<"rabbitmq.configure:vhost/one">>,
<<"rabbitmq.write:vhost/two">>,
<<"rabbitmq.read:vhost/one">>,
<<"rabbitmq.read:vhost/two">>,
<<"rabbitmq.read:vhost/two/abc">>,
<<"rabbitmq.tag:management">>,
<<"rabbitmq.tag:custom">>
]
}),
VHost = <<"vhost">>,
Username = <<"username">>,
Token = ?UTIL_MOD:sign_token_hs(?UTIL_MOD:token_with_scope_alias_in_scope_field(Alias), Jwk),
{ok, #auth_user{username = Username, tags = [custom, management]} = AuthUser} =
rabbit_auth_backend_oauth2:user_login_authentication(Username, [{password, Token}]),
assert_vhost_access_granted(AuthUser, VHost),
assert_vhost_access_denied(AuthUser, <<"some-other-vhost">>),
assert_resource_access_granted(AuthUser, VHost, <<"one">>, configure),
assert_resource_access_granted(AuthUser, VHost, <<"one">>, read),
assert_resource_access_granted(AuthUser, VHost, <<"two">>, read),
assert_resource_access_granted(AuthUser, VHost, <<"two">>, write),
assert_resource_access_denied(AuthUser, VHost, <<"three">>, configure),
assert_resource_access_denied(AuthUser, VHost, <<"three">>, read),
assert_resource_access_denied(AuthUser, VHost, <<"three">>, write),
application:unset_env(rabbitmq_auth_backend_oauth2, scope_aliases),
application:unset_env(rabbitmq_auth_backend_oauth2, key_config),
application:unset_env(rabbitmq_auth_backend_oauth2, resource_server_id).
test_unsuccessful_access_with_a_token_that_uses_missing_scope_alias_in_scope_field(_) ->
Jwk = ?UTIL_MOD:fixture_jwk(),
UaaEnv = [{signing_keys, #{<<"token-key">> => {map, Jwk}}}],
application:set_env(rabbitmq_auth_backend_oauth2, key_config, UaaEnv),
application:set_env(rabbitmq_auth_backend_oauth2, resource_server_id, <<"rabbitmq">>),
Alias = <<"client-alias-33">>,
application:set_env(rabbitmq_auth_backend_oauth2, scope_aliases, #{
<<"non-existent-alias-23948sdkfjsdof8">> => [
<<"rabbitmq.configure:vhost/one">>,
<<"rabbitmq.write:vhost/two">>,
<<"rabbitmq.read:vhost/one">>,
<<"rabbitmq.read:vhost/two">>,
<<"rabbitmq.read:vhost/two/abc">>
]
}),
VHost = <<"vhost">>,
Username = <<"username">>,
Token = ?UTIL_MOD:sign_token_hs(?UTIL_MOD:token_with_scope_alias_in_scope_field(Alias), Jwk),
{ok, AuthUser} = rabbit_auth_backend_oauth2:user_login_authentication(Username, [{password, Token}]),
assert_vhost_access_denied(AuthUser, VHost),
assert_vhost_access_denied(AuthUser, <<"some-other-vhost">>),
assert_resource_access_denied(AuthUser, VHost, <<"one">>, configure),
assert_resource_access_denied(AuthUser, VHost, <<"one">>, read),
assert_resource_access_denied(AuthUser, VHost, <<"two">>, read),
assert_resource_access_denied(AuthUser, VHost, <<"two">>, write),
assert_resource_access_denied(AuthUser, VHost, <<"three">>, configure),
assert_resource_access_denied(AuthUser, VHost, <<"three">>, read),
assert_resource_access_denied(AuthUser, VHost, <<"three">>, write),
application:unset_env(rabbitmq_auth_backend_oauth2, scope_aliases),
application:unset_env(rabbitmq_auth_backend_oauth2, key_config),
application:unset_env(rabbitmq_auth_backend_oauth2, resource_server_id).
test_successful_access_with_a_token_that_uses_scope_alias_in_extra_scope_source_field(_) ->
Jwk = ?UTIL_MOD:fixture_jwk(),
UaaEnv = [{signing_keys, #{<<"token-key">> => {map, Jwk}}}],
application:set_env(rabbitmq_auth_backend_oauth2, key_config, UaaEnv),
application:set_env(rabbitmq_auth_backend_oauth2, extra_scopes_source, <<"claims">>),
application:set_env(rabbitmq_auth_backend_oauth2, resource_server_id, <<"rabbitmq">>),
Alias = <<"client-alias-1">>,
application:set_env(rabbitmq_auth_backend_oauth2, scope_aliases, #{
Alias => [
<<"rabbitmq.configure:vhost/one">>,
<<"rabbitmq.write:vhost/two">>,
<<"rabbitmq.read:vhost/one">>,
<<"rabbitmq.read:vhost/two">>,
<<"rabbitmq.read:vhost/two/abc">>
]
}),
VHost = <<"vhost">>,
Username = <<"username">>,
Token = ?UTIL_MOD:sign_token_hs(?UTIL_MOD:token_with_scope_alias_in_claim_field(Alias, [<<"unrelated">>]), Jwk),
{ok, AuthUser} = rabbit_auth_backend_oauth2:user_login_authentication(Username, [{password, Token}]),
assert_vhost_access_granted(AuthUser, VHost),
assert_vhost_access_denied(AuthUser, <<"some-other-vhost">>),
assert_resource_access_granted(AuthUser, VHost, <<"one">>, configure),
assert_resource_access_granted(AuthUser, VHost, <<"one">>, read),
assert_resource_access_granted(AuthUser, VHost, <<"two">>, read),
assert_resource_access_granted(AuthUser, VHost, <<"two">>, write),
assert_resource_access_denied(AuthUser, VHost, <<"three">>, configure),
assert_resource_access_denied(AuthUser, VHost, <<"three">>, read),
assert_resource_access_denied(AuthUser, VHost, <<"three">>, write),
application:unset_env(rabbitmq_auth_backend_oauth2, scope_aliases),
application:unset_env(rabbitmq_auth_backend_oauth2, key_config),
application:unset_env(rabbitmq_auth_backend_oauth2, resource_server_id).
test_unsuccessful_access_with_a_token_that_uses_missing_scope_alias_in_extra_scope_source_field(_) ->
Jwk = ?UTIL_MOD:fixture_jwk(),
UaaEnv = [{signing_keys, #{<<"token-key">> => {map, Jwk}}}],
application:set_env(rabbitmq_auth_backend_oauth2, key_config, UaaEnv),
application:set_env(rabbitmq_auth_backend_oauth2, extra_scopes_source, <<"claims">>),
application:set_env(rabbitmq_auth_backend_oauth2, resource_server_id, <<"rabbitmq">>),
Alias = <<"client-alias-11">>,
application:set_env(rabbitmq_auth_backend_oauth2, scope_aliases, #{
<<"non-existent-client-alias-9238923789">> => [
<<"rabbitmq.configure:vhost/one">>,
<<"rabbitmq.write:vhost/two">>,
<<"rabbitmq.read:vhost/one">>,
<<"rabbitmq.read:vhost/two">>,
<<"rabbitmq.read:vhost/two/abc">>
]
}),
VHost = <<"vhost">>,
Username = <<"username">>,
Token = ?UTIL_MOD:sign_token_hs(?UTIL_MOD:token_with_scope_alias_in_claim_field(Alias, [<<"unrelated">>]), Jwk),
{ok, AuthUser} = rabbit_auth_backend_oauth2:user_login_authentication(Username, [{password, Token}]),
assert_vhost_access_denied(AuthUser, VHost),
assert_vhost_access_denied(AuthUser, <<"some-other-vhost">>),
assert_resource_access_denied(AuthUser, VHost, <<"one">>, configure),
assert_resource_access_denied(AuthUser, VHost, <<"one">>, read),
assert_resource_access_denied(AuthUser, VHost, <<"two">>, read),
assert_resource_access_denied(AuthUser, VHost, <<"two">>, write),
assert_resource_access_denied(AuthUser, VHost, <<"three">>, configure),
assert_resource_access_denied(AuthUser, VHost, <<"three">>, read),
assert_resource_access_denied(AuthUser, VHost, <<"three">>, write),
application:unset_env(rabbitmq_auth_backend_oauth2, scope_aliases),
application:unset_env(rabbitmq_auth_backend_oauth2, key_config),
application:unset_env(rabbitmq_auth_backend_oauth2, resource_server_id).
test_unsuccessful_access_with_a_bogus_token(_) ->
Username = <<"username">>,
application:set_env(rabbitmq_auth_backend_oauth2, resource_server_id, <<"rabbitmq">>),
@ -316,6 +447,7 @@ test_restricted_vhost_access_with_a_valid_token(_) ->
?assertEqual(false, rabbit_auth_backend_oauth2:check_vhost_access(User, <<"different vhost">>, none)).
test_insufficient_permissions_in_a_valid_token(_) ->
VHost = <<"vhost">>,
Username = <<"username">>,
application:set_env(rabbitmq_auth_backend_oauth2, resource_server_id, <<"rabbitmq">>),
@ -328,29 +460,12 @@ test_insufficient_permissions_in_a_valid_token(_) ->
rabbit_auth_backend_oauth2:user_login_authentication(Username, [{password, Token}]),
%% access to these resources is not granted
?assertEqual(false, rabbit_auth_backend_oauth2:check_resource_access(
User,
#resource{virtual_host = <<"vhost">>,
kind = queue,
name = <<"foo1">>},
configure,
#{})),
?assertEqual(false, rabbit_auth_backend_oauth2:check_resource_access(
User,
#resource{virtual_host = <<"vhost">>,
kind = custom,
name = <<"bar">>},
write,
#{})),
?assertEqual(false, rabbit_auth_backend_oauth2:check_topic_access(
User,
#resource{virtual_host = <<"vhost">>,
kind = topic,
name = <<"bar">>},
read,
#{routing_key => <<"foo/#">>})).
assert_resource_access_denied(User, VHost, <<"foo1">>, configure),
assert_resource_access_denied(User, VHost, <<"bar">>, write),
assert_topic_access_refused(User, VHost, <<"bar">>, read, #{routing_key => <<"foo/#">>}).
test_token_expiration(_) ->
VHost = <<"vhost">>,
Username = <<"username">>,
Jwk = ?UTIL_MOD:fixture_jwk(),
UaaEnv = [{signing_keys, #{<<"token-key">> => {map, Jwk}}}],
@ -361,32 +476,14 @@ test_token_expiration(_) ->
Token = ?UTIL_MOD:sign_token_hs(TokenData, Jwk),
{ok, #auth_user{username = Username} = User} =
rabbit_auth_backend_oauth2:user_login_authentication(Username, [{password, Token}]),
?assertEqual(true, rabbit_auth_backend_oauth2:check_resource_access(
User,
#resource{virtual_host = <<"vhost">>,
kind = queue,
name = <<"foo">>},
configure,
#{})),
?assertEqual(true, rabbit_auth_backend_oauth2:check_resource_access(
User,
#resource{virtual_host = <<"vhost">>,
kind = exchange,
name = <<"foo">>},
write,
#{})),
assert_resource_access_granted(User, VHost, <<"foo">>, configure),
assert_resource_access_granted(User, VHost, <<"foo">>, write),
?UTIL_MOD:wait_for_token_to_expire(),
#{<<"exp">> := Exp} = TokenData,
ExpectedError = "Provided JWT token has expired at timestamp " ++ integer_to_list(Exp) ++ " (validated at " ++ integer_to_list(Exp) ++ ")",
?assertEqual({error, ExpectedError},
rabbit_auth_backend_oauth2:check_resource_access(
User,
#resource{virtual_host = <<"vhost">>,
kind = queue,
name = <<"foo">>},
configure,
#{})),
assert_resource_access_errors(ExpectedError, User, VHost, <<"foo">>, configure),
?assertMatch({refused, _, _},
rabbit_auth_backend_oauth2:user_login_authentication(Username, [{password, Token}])).
@ -549,3 +646,65 @@ test_validate_payload(_) ->
?assertEqual({ok, #{<<"aud">> => [?RESOURCE_SERVER_ID],
<<"scope">> => [<<"bar">>, <<"other.third">>]}},
rabbit_auth_backend_oauth2:validate_payload(KnownResourceServerId, ?RESOURCE_SERVER_ID)).
%%
%% Helpers
%%
assert_vhost_access_granted(AuthUser, VHost) ->
assert_vhost_access_response(true, AuthUser, VHost).
assert_vhost_access_denied(AuthUser, VHost) ->
assert_vhost_access_response(false, AuthUser, VHost).
assert_vhost_access_response(ExpectedResult, AuthUser, VHost) ->
?assertEqual(ExpectedResult,
rabbit_auth_backend_oauth2:check_vhost_access(AuthUser, VHost, none)).
assert_resource_access_granted(AuthUser, VHost, ResourceName, PermissionKind) ->
assert_resource_access_response(true, AuthUser, VHost, ResourceName, PermissionKind).
assert_resource_access_denied(AuthUser, VHost, ResourceName, PermissionKind) ->
assert_resource_access_response(false, AuthUser, VHost, ResourceName, PermissionKind).
assert_resource_access_errors(ExpectedError, AuthUser, VHost, ResourceName, PermissionKind) ->
assert_resource_access_response({error, ExpectedError}, AuthUser, VHost, ResourceName, PermissionKind).
assert_resource_access_response(ExpectedResult, AuthUser, VHost, ResourceName, PermissionKind) ->
?assertEqual(ExpectedResult,
rabbit_auth_backend_oauth2:check_resource_access(
AuthUser,
rabbit_misc:r(VHost, queue, ResourceName),
PermissionKind, #{})).
assert_resource_access_granted(AuthUser, VHost, ResourceKind, ResourceName, PermissionKind) ->
assert_resource_access_response(true, AuthUser, VHost, ResourceKind, ResourceName, PermissionKind).
assert_resource_access_denied(AuthUser, VHost, ResourceKind, ResourceName, PermissionKind) ->
assert_resource_access_response(false, AuthUser, VHost, ResourceKind, ResourceName, PermissionKind).
assert_resource_access_errors(ExpectedError, AuthUser, VHost, ResourceKind, ResourceName, PermissionKind) ->
assert_resource_access_response({error, ExpectedError}, AuthUser, VHost, ResourceKind, ResourceName, PermissionKind).
assert_resource_access_response(ExpectedResult, AuthUser, VHost, ResourceKind, ResourceName, PermissionKind) ->
?assertEqual(ExpectedResult,
rabbit_auth_backend_oauth2:check_resource_access(
AuthUser,
rabbit_misc:r(VHost, ResourceKind, ResourceName),
PermissionKind, #{})).
assert_topic_access_granted(AuthUser, VHost, ResourceName, PermissionKind, AuthContext) ->
assert_topic_access_response(true, AuthUser, VHost, ResourceName, PermissionKind, AuthContext).
assert_topic_access_refused(AuthUser, VHost, ResourceName, PermissionKind, AuthContext) ->
assert_topic_access_response(false, AuthUser, VHost, ResourceName, PermissionKind, AuthContext).
assert_topic_access_response(ExpectedResult, AuthUser, VHost, ResourceName, PermissionKind, AuthContext) ->
?assertEqual(ExpectedResult, rabbit_auth_backend_oauth2:check_topic_access(
AuthUser,
#resource{virtual_host = VHost,
kind = topic,
name = ResourceName},
PermissionKind,
AuthContext)).

View File

@ -283,6 +283,16 @@ This release includes all applicable [bug fixes that shipped in `3.9.x` releases
#### Enhancements
* The plugin now supports scope aliases. In some environments, it's unrealistic to
adopt JWTs that follow the `scope` convention assumed by the plugin. Instead,
identity services fill `scope` or `claims` field with a "role name" or "role alias"
that implicitly maps to a set of scopes/permissions.
With this feature, RabbitMQ operators can map those values to a set of
scopes that can be translated to RabbitMQ permissions.
GitHub issue: [#4588](https://github.com/rabbitmq/rabbitmq-server/issues/4588)
* Improvements to JKW support and new HTTPS settings.
Contributed by @anhanhnguyen (Erlang Solutions).