diff options
Diffstat (limited to 'deps/rabbitmq_auth_backend_oauth2/src')
8 files changed, 889 insertions, 0 deletions
diff --git a/deps/rabbitmq_auth_backend_oauth2/src/Elixir.RabbitMQ.CLI.Ctl.Commands.AddUaaKeyCommand.erl b/deps/rabbitmq_auth_backend_oauth2/src/Elixir.RabbitMQ.CLI.Ctl.Commands.AddUaaKeyCommand.erl new file mode 100644 index 0000000000..6571ba9c59 --- /dev/null +++ b/deps/rabbitmq_auth_backend_oauth2/src/Elixir.RabbitMQ.CLI.Ctl.Commands.AddUaaKeyCommand.erl @@ -0,0 +1,143 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2020 VMware, Inc. or its affiliates. All rights reserved. +%% +-module('Elixir.RabbitMQ.CLI.Ctl.Commands.AddUaaKeyCommand'). + +-behaviour('Elixir.RabbitMQ.CLI.CommandBehaviour'). + +-export([ + usage/0, + validate/2, + merge_defaults/2, + banner/2, + run/2, + switches/0, + aliases/0, + output/2, + formatter/0 + ]). + + +usage() -> + <<"add_uaa_key <name> [--json=<json_key>] [--pem=<public_key>] [--pem_file=<pem_file>]">>. + +switches() -> + [{json, string}, + {pem, string}, + {pem_file, string}]. + +aliases() -> []. + +validate([], _Options) -> {validation_failure, not_enough_args}; +validate([_,_|_], _Options) -> {validation_failure, too_many_args}; +validate([_], Options) -> + Json = maps:get(json, Options, undefined), + Pem = maps:get(pem, Options, undefined), + PemFile = maps:get(pem_file, Options, undefined), + case {is_binary(Json), is_binary(Pem), is_binary(PemFile)} of + {false, false, false} -> + {validation_failure, + {bad_argument, <<"No key specified">>}}; + {true, false, false} -> + validate_json(Json); + {false, true, false} -> + validate_pem(Pem); + {false, false, true} -> + validate_pem_file(PemFile); + {_, _, _} -> + {validation_failure, + {bad_argument, <<"There can be only one key type">>}} + end. + +validate_json(Json) -> + case rabbit_json:try_decode(Json) of + {ok, _} -> + case uaa_jwt:verify_signing_key(json, Json) of + ok -> ok; + {error, {fields_missing_for_kty, Kty}} -> + {validation_failure, + {bad_argument, + <<"Key fields are missing fot kty \"", Kty/binary, "\"">>}}; + {error, unknown_kty} -> + {validation_failure, + {bad_argument, <<"\"kty\" field is invalid">>}}; + {error, no_kty} -> + {validation_failure, + {bad_argument, <<"Json key should contain \"kty\" field">>}}; + {error, Err} -> + {validation_failure, {bad_argument, Err}} + end; + {error, _} -> + {validation_failure, {bad_argument, <<"Invalid JSON">>}}; + error -> + {validation_failure, {bad_argument, <<"Invalid JSON">>}} + end. + +validate_pem(Pem) -> + case uaa_jwt:verify_signing_key(pem, Pem) of + ok -> ok; + {error, invalid_pem_string} -> + {validation_failure, <<"Unable to read a key from the PEM string">>}; + {error, Err} -> + {validation_failure, Err} + end. + +validate_pem_file(PemFile) -> + case uaa_jwt:verify_signing_key(pem_file, PemFile) of + ok -> ok; + {error, enoent} -> + {validation_failure, {bad_argument, <<"PEM file not found">>}}; + {error, invalid_pem_file} -> + {validation_failure, <<"Unable to read a key from the PEM file">>}; + {error, Err} -> + {validation_failure, Err} + end. + +merge_defaults(Args, #{pem_file := FileName} = Options) -> + AbsFileName = filename:absname(FileName), + {Args, Options#{pem_file := AbsFileName}}; +merge_defaults(Args, Options) -> {Args, Options}. + +banner([Name], #{json := Json}) -> + <<"Adding UAA signing key \"", + Name/binary, + "\" in JSON format: \"", + Json/binary, "\"">>; +banner([Name], #{pem := Pem}) -> + <<"Adding UAA signing key \"", + Name/binary, + "\" public key: \"", + Pem/binary, "\"">>; +banner([Name], #{pem_file := PemFile}) -> + <<"Adding UAA signing key \"", + Name/binary, + "\" filename: \"", + PemFile/binary, "\"">>. + +run([Name], #{node := Node} = Options) -> + {Type, Value} = case Options of + #{json := Json} -> {json, Json}; + #{pem := Pem} -> {pem, Pem}; + #{pem_file := PemFile} -> {pem_file, PemFile} + end, + case rabbit_misc:rpc_call(Node, + uaa_jwt, add_signing_key, + [Name, Type, Value]) of + {ok, _Keys} -> ok; + {error, Err} -> {error, Err} + end. + +output(E, _Opts) -> + 'Elixir.RabbitMQ.CLI.DefaultOutput':output(E). + +formatter() -> 'Elixir.RabbitMQ.CLI.Formatters.Erlang'. + + + + + + + diff --git a/deps/rabbitmq_auth_backend_oauth2/src/rabbit_auth_backend_oauth2.erl b/deps/rabbitmq_auth_backend_oauth2/src/rabbit_auth_backend_oauth2.erl new file mode 100644 index 0000000000..e1a99ab7ea --- /dev/null +++ b/deps/rabbitmq_auth_backend_oauth2/src/rabbit_auth_backend_oauth2.erl @@ -0,0 +1,318 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2020 VMware, Inc. or its affiliates. All rights reserved. +%% + +-module(rabbit_auth_backend_oauth2). + +-include_lib("rabbit_common/include/rabbit.hrl"). + +-behaviour(rabbit_authn_backend). +-behaviour(rabbit_authz_backend). + +-export([description/0]). +-export([user_login_authentication/2, user_login_authorization/2, + check_vhost_access/3, check_resource_access/4, + check_topic_access/4, check_token/1, state_can_expire/0, update_state/2]). + +% for testing +-export([post_process_payload/1]). + +-import(rabbit_data_coercion, [to_map/1]). + +-ifdef(TEST). +-compile(export_all). +-endif. +%%-------------------------------------------------------------------- + +-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). + +description() -> + [{name, <<"OAuth 2">>}, + {description, <<"Performs authentication and authorisation using JWT tokens and OAuth 2 scopes">>}]. + +%%-------------------------------------------------------------------- + +user_login_authentication(Username, AuthProps) -> + case authenticate(Username, AuthProps) of + {refused, Msg, Args} = AuthResult -> + rabbit_log:debug(Msg ++ "~n", Args), + AuthResult; + _ = AuthResult -> + AuthResult + end. + +user_login_authorization(Username, AuthProps) -> + case authenticate(Username, AuthProps) of + {ok, #auth_user{impl = Impl}} -> {ok, Impl}; + Else -> Else + end. + +check_vhost_access(#auth_user{impl = DecodedToken}, + VHost, _AuthzData) -> + with_decoded_token(DecodedToken, + fun() -> + Scopes = get_scopes(DecodedToken), + ScopeString = rabbit_oauth2_scope:concat_scopes(Scopes, ","), + rabbit_log:debug("Matching virtual host '~s' against the following scopes: ~s", [VHost, ScopeString]), + rabbit_oauth2_scope:vhost_access(VHost, Scopes) + end). + +check_resource_access(#auth_user{impl = DecodedToken}, + Resource, Permission, _AuthzContext) -> + with_decoded_token(DecodedToken, + fun() -> + Scopes = get_scopes(DecodedToken), + rabbit_oauth2_scope:resource_access(Resource, Permission, Scopes) + end). + +check_topic_access(#auth_user{impl = DecodedToken}, + Resource, Permission, Context) -> + with_decoded_token(DecodedToken, + fun() -> + Scopes = get_scopes(DecodedToken), + rabbit_oauth2_scope:topic_access(Resource, Permission, Context, Scopes) + end). + +state_can_expire() -> true. + +update_state(AuthUser, NewToken) -> + case check_token(NewToken) of + %% avoid logging the token + {error, _} = E -> E; + {refused, {error, {invalid_token, error, _Err, _Stacktrace}}} -> + {refused, "Authentication using an OAuth 2/JWT token failed: provided token is invalid"}; + {refused, Err} -> + {refused, rabbit_misc:format("Authentication using an OAuth 2/JWT token failed: ~p", [Err])}; + {ok, DecodedToken} -> + Tags = tags_from(DecodedToken), + + {ok, AuthUser#auth_user{tags = Tags, + impl = DecodedToken}} + end. + +%%-------------------------------------------------------------------- + +authenticate(Username0, AuthProps0) -> + AuthProps = to_map(AuthProps0), + Token = token_from_context(AuthProps), + case check_token(Token) of + %% avoid logging the token + {error, _} = E -> E; + {refused, {error, {invalid_token, error, _Err, _Stacktrace}}} -> + {refused, "Authentication using an OAuth 2/JWT token failed: provided token is invalid", []}; + {refused, Err} -> + {refused, "Authentication using an OAuth 2/JWT token failed: ~p", [Err]}; + {ok, DecodedToken} -> + Func = fun() -> + Username = username_from(Username0, DecodedToken), + Tags = tags_from(DecodedToken), + + {ok, #auth_user{username = Username, + tags = Tags, + impl = DecodedToken}} + end, + case with_decoded_token(DecodedToken, Func) of + {error, Err} -> + {refused, "Authentication using an OAuth 2/JWT token failed: ~p", [Err]}; + Else -> + Else + end + end. + +with_decoded_token(DecodedToken, Fun) -> + case validate_token_expiry(DecodedToken) of + ok -> Fun(); + {error, Msg} = Err -> + rabbit_log:error(Msg), + Err + end. + +validate_token_expiry(#{<<"exp">> := Exp}) when is_integer(Exp) -> + Now = os:system_time(seconds), + case Exp =< Now of + true -> {error, rabbit_misc:format("Provided JWT token has expired at timestamp ~p (validated at ~p)", [Exp, Now])}; + false -> ok + end; +validate_token_expiry(#{}) -> ok. + +-spec check_token(binary()) -> {ok, map()} | {error, term()}. +check_token(Token) -> + case uaa_jwt:decode_and_verify(Token) of + {error, Reason} -> {refused, {error, Reason}}; + {true, Payload} -> validate_payload(post_process_payload(Payload)); + {false, _} -> {refused, signature_invalid} + end. + +post_process_payload(Payload) 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]); + _ -> V + end + end, + Payload + ), + Payload1 = case does_include_complex_claim_field(Payload0) of + true -> post_process_payload_complex_claim(Payload0); + false -> Payload0 + end, + + Payload2 = case maps:is_key(<<"authorization">>, Payload1) of + true -> post_process_payload_keycloak(Payload1); + false -> Payload1 + end, + + Payload2. + +does_include_complex_claim_field(Payload) when is_map(Payload) -> + maps:is_key(application:get_env(?APP, ?COMPLEX_CLAIM, undefined), Payload). + +post_process_payload_complex_claim(Payload) -> + ComplexClaim = maps:get(application:get_env(?APP, ?COMPLEX_CLAIM, undefined), Payload), + ResourceServerId = rabbit_data_coercion:to_binary(application:get_env(?APP, ?RESOURCE_SERVER_ID, <<>>)), + + AdditionalScopes = + case ComplexClaim of + L when is_list(L) -> L; + M when is_map(M) -> + case maps:get(ResourceServerId, M, undefined) of + undefined -> []; + Ks when is_list(Ks) -> + [erlang:iolist_to_binary([ResourceServerId, <<".">>, K]) || K <- Ks]; + ClaimBin when is_binary(ClaimBin) -> + UnprefixedClaims = binary:split(ClaimBin, <<" ">>, [global, trim_all]), + [erlang:iolist_to_binary([ResourceServerId, <<".">>, K]) || K <- UnprefixedClaims]; + _ -> [] + end; + Bin when is_binary(Bin) -> + binary:split(Bin, <<" ">>, [global, trim_all]); + _ -> [] + end, + + case AdditionalScopes of + [] -> Payload; + _ -> + ExistingScopes = maps:get(<<"scope">>, Payload, []), + maps:put(<<"scope">>, AdditionalScopes ++ ExistingScopes, Payload) + end. + +%% keycloak token format: https://github.com/rabbitmq/rabbitmq-auth-backend-oauth2/issues/36 +post_process_payload_keycloak(#{<<"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). + +extract_scopes_from_keycloak_permissions(Acc, []) -> + Acc; +extract_scopes_from_keycloak_permissions(Acc, [H | T]) when is_map(H) -> + Scopes = case maps:get(<<"scopes">>, H, []) of + ScopesAsList when is_list(ScopesAsList) -> + ScopesAsList; + ScopesAsBinary when is_binary(ScopesAsBinary) -> + [ScopesAsBinary] + end, + extract_scopes_from_keycloak_permissions(Acc ++ Scopes, T); +extract_scopes_from_keycloak_permissions(Acc, [_ | T]) -> + extract_scopes_from_keycloak_permissions(Acc, T). + +validate_payload(#{<<"scope">> := _Scope, <<"aud">> := _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) -> + case check_aud(Aud, ResourceServerId) of + ok -> {ok, DecodedToken#{<<"scope">> => filter_scopes(Scope, ResourceServerId)}}; + {error, Err} -> {refused, {invalid_aud, Err}} + end. + +filter_scopes(Scopes, <<"">>) -> Scopes; +filter_scopes(Scopes, ResourceServerId) -> + PrefixPattern = <<ResourceServerId/binary, ".">>, + matching_scopes_without_prefix(Scopes, PrefixPattern). + +check_aud(_, <<>>) -> ok; +check_aud(Aud, ResourceServerId) -> + case Aud of + List when is_list(List) -> + case lists:member(ResourceServerId, Aud) of + true -> ok; + false -> {error, {resource_id_not_found_in_aud, ResourceServerId, Aud}} + end; + _ -> {error, {badarg, {aud_is_not_a_list, Aud}}} + end. + +%%-------------------------------------------------------------------- + +get_scopes(#{<<"scope">> := Scope}) -> Scope. + +-spec token_from_context(map()) -> binary() | undefined. +token_from_context(AuthProps) -> + maps:get(password, AuthProps, undefined). + +%% Decoded tokens look like this: +%% +%% #{<<"aud">> => [<<"rabbitmq">>, <<"rabbit_client">>], +%% <<"authorities">> => [<<"rabbitmq.read:*/*">>, <<"rabbitmq.write:*/*">>, <<"rabbitmq.configure:*/*">>], +%% <<"azp">> => <<"rabbit_client">>, +%% <<"cid">> => <<"rabbit_client">>, +%% <<"client_id">> => <<"rabbit_client">>, +%% <<"exp">> => 1530849387, +%% <<"grant_type">> => <<"client_credentials">>, +%% <<"iat">> => 1530806187, +%% <<"iss">> => <<"http://localhost:8080/uaa/oauth/token">>, +%% <<"jti">> => <<"df5d50a1cdcb4fa6bf32e7e03acfc74d">>, +%% <<"rev_sig">> => <<"2f880d5b">>, +%% <<"scope">> => [<<"rabbitmq.read:*/*">>, <<"rabbitmq.write:*/*">>, <<"rabbitmq.configure:*/*">>], +%% <<"sub">> => <<"rabbit_client">>, +%% <<"zid">> => <<"uaa">>} + +-spec username_from(binary(), map()) -> binary() | undefined. +username_from(ClientProvidedUsername, DecodedToken) -> + ClientId = uaa_jwt:client_id(DecodedToken, undefined), + Sub = uaa_jwt:sub(DecodedToken, undefined), + + rabbit_log:debug("Computing username from client's JWT token, client ID: '~s', sub: '~s'", + [ClientId, Sub]), + + case uaa_jwt:client_id(DecodedToken, Sub) of + undefined -> + case ClientProvidedUsername of + undefined -> undefined; + <<>> -> undefined; + _Other -> ClientProvidedUsername + end; + Value -> + Value + end. + +-spec tags_from(map()) -> list(atom()). +tags_from(DecodedToken) -> + Scopes = maps:get(<<"scope">>, DecodedToken, []), + TagScopes = matching_scopes_without_prefix(Scopes, <<"tag:">>), + lists:usort(lists:map(fun rabbit_data_coercion:to_atom/1, TagScopes)). + +matching_scopes_without_prefix(Scopes, PrefixPattern) -> + PatternLength = byte_size(PrefixPattern), + lists:filtermap( + fun(ScopeEl) -> + case binary:match(ScopeEl, PrefixPattern) of + {0, PatternLength} -> + ElLength = byte_size(ScopeEl), + {true, + binary:part(ScopeEl, + {PatternLength, ElLength - PatternLength})}; + _ -> false + end + end, + Scopes). diff --git a/deps/rabbitmq_auth_backend_oauth2/src/rabbit_auth_backend_oauth2_app.erl b/deps/rabbitmq_auth_backend_oauth2/src/rabbit_auth_backend_oauth2_app.erl new file mode 100644 index 0000000000..e2b1d23131 --- /dev/null +++ b/deps/rabbitmq_auth_backend_oauth2/src/rabbit_auth_backend_oauth2_app.erl @@ -0,0 +1,26 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2020 VMware, Inc. or its affiliates. All rights reserved. +%% + +-module(rabbit_auth_backend_oauth2_app). + +-behaviour(application). +-export([start/2, stop/1]). + +-behaviour(supervisor). +-export([init/1]). + +start(_Type, _StartArgs) -> + supervisor:start_link({local,?MODULE},?MODULE,[]). + +stop(_State) -> + ok. + +%%---------------------------------------------------------------------------- + +init([]) -> + {ok, {{one_for_one,3,10},[]}}. + diff --git a/deps/rabbitmq_auth_backend_oauth2/src/rabbit_oauth2_scope.erl b/deps/rabbitmq_auth_backend_oauth2/src/rabbit_oauth2_scope.erl new file mode 100644 index 0000000000..2ebf6c3c52 --- /dev/null +++ b/deps/rabbitmq_auth_backend_oauth2/src/rabbit_oauth2_scope.erl @@ -0,0 +1,90 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2020 VMware, Inc. or its affiliates. All rights reserved. +%% + +-module(rabbit_oauth2_scope). + +-export([vhost_access/2, resource_access/3, topic_access/4, concat_scopes/2]). + +-include_lib("rabbit_common/include/rabbit.hrl"). + +-type permission() :: read | write | configure. + +%% API functions -------------------------------------------------------------- +-spec vhost_access(binary(), [binary()]) -> boolean(). +vhost_access(VHost, Scopes) -> + PermissionScopes = get_scope_permissions(Scopes), + lists:any( + fun({VHostPattern, _, _, _}) -> + wildcard:match(VHost, VHostPattern) + end, + PermissionScopes). + +-spec resource_access(rabbit_types:r(atom()), permission(), [binary()]) -> boolean(). +resource_access(#resource{virtual_host = VHost, name = Name}, + Permission, Scopes) -> + lists:any( + fun({VHostPattern, NamePattern, _, ScopeGrantedPermission}) -> + wildcard:match(VHost, VHostPattern) andalso + wildcard:match(Name, NamePattern) andalso + Permission =:= ScopeGrantedPermission + end, + get_scope_permissions(Scopes)). + +topic_access(#resource{virtual_host = VHost, name = ExchangeName}, + Permission, + #{routing_key := RoutingKey}, + Scopes) -> + lists:any( + fun({VHostPattern, ExchangeNamePattern, RoutingKeyPattern, ScopeGrantedPermission}) -> + is_binary(RoutingKeyPattern) andalso + wildcard:match(VHost, VHostPattern) andalso + wildcard:match(ExchangeName, ExchangeNamePattern) andalso + wildcard:match(RoutingKey, RoutingKeyPattern) andalso + Permission =:= ScopeGrantedPermission + end, + get_scope_permissions(Scopes)). + +%% Internal ------------------------------------------------------------------- + +-spec get_scope_permissions([binary()]) -> [{rabbit_types:r(pattern), permission()}]. +get_scope_permissions(Scopes) when is_list(Scopes) -> + lists:filtermap( + fun(ScopeEl) -> + case parse_permission_pattern(ScopeEl) of + ignore -> false; + Perm -> {true, Perm} + end + end, + Scopes). + +-spec concat_scopes([binary()], string()) -> string(). +concat_scopes(Scopes, Separator) when is_list(Scopes) -> + lists:concat(lists:join(Separator, lists:map(fun rabbit_data_coercion:to_list/1, Scopes))). + +-spec parse_permission_pattern(binary()) -> {rabbit_types:r(pattern), permission()}. +parse_permission_pattern(<<"read:", ResourcePatternBin/binary>>) -> + Permission = read, + parse_resource_pattern(ResourcePatternBin, Permission); +parse_permission_pattern(<<"write:", ResourcePatternBin/binary>>) -> + Permission = write, + parse_resource_pattern(ResourcePatternBin, Permission); +parse_permission_pattern(<<"configure:", ResourcePatternBin/binary>>) -> + Permission = configure, + parse_resource_pattern(ResourcePatternBin, Permission); +parse_permission_pattern(_Other) -> + ignore. + +-spec parse_resource_pattern(binary(), permission()) -> + {rabbit_types:vhost(), binary(), binary() | none, permission()}. +parse_resource_pattern(Pattern, Permission) -> + case binary:split(Pattern, <<"/">>, [global]) of + [VhostPattern, NamePattern] -> + {VhostPattern, NamePattern, none, Permission}; + [VhostPattern, NamePattern, RoutingKeyPattern] -> + {VhostPattern, NamePattern, RoutingKeyPattern, Permission}; + _Other -> ignore + end. diff --git a/deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt.erl b/deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt.erl new file mode 100644 index 0000000000..e8d59f5670 --- /dev/null +++ b/deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt.erl @@ -0,0 +1,122 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2020 VMware, Inc. or its affiliates. All rights reserved. +%% +-module(uaa_jwt). + +-export([add_signing_key/3, + remove_signing_key/1, + decode_and_verify/1, + get_jwk/1, + verify_signing_key/2, + signing_keys/0]). + +-export([client_id/1, sub/1, client_id/2, sub/2]). + +-include_lib("jose/include/jose_jwk.hrl"). + +-define(APP, rabbitmq_auth_backend_oauth2). + +-type key_type() :: json | pem | map. + +-spec add_signing_key(binary(), key_type(), binary() | map()) -> {ok, map()} | {error, term()}. + +add_signing_key(KeyId, Type, Value) -> + case verify_signing_key(Type, Value) of + ok -> + SigningKeys0 = signing_keys(), + SigningKeys1 = maps:put(KeyId, {Type, Value}, SigningKeys0), + ok = update_uaa_jwt_signing_keys(SigningKeys1), + {ok, SigningKeys1}; + {error, _} = Err -> + Err + end. + +remove_signing_key(KeyId) -> + UaaEnv = application:get_env(?APP, key_config, []), + Keys0 = proplists:get_value(signing_keys, UaaEnv), + Keys1 = maps:remove(KeyId, Keys0), + update_uaa_jwt_signing_keys(UaaEnv, Keys1). + +-spec update_uaa_jwt_signing_keys(map()) -> ok. +update_uaa_jwt_signing_keys(SigningKeys) -> + UaaEnv0 = application:get_env(?APP, key_config, []), + update_uaa_jwt_signing_keys(UaaEnv0, SigningKeys). + +-spec update_uaa_jwt_signing_keys([term()], map()) -> ok. +update_uaa_jwt_signing_keys(UaaEnv0, SigningKeys) -> + UaaEnv1 = proplists:delete(signing_keys, UaaEnv0), + UaaEnv2 = [{signing_keys, SigningKeys} | UaaEnv1], + application:set_env(?APP, key_config, UaaEnv2). + +-spec decode_and_verify(binary()) -> {boolean(), map()} | {error, term()}. +decode_and_verify(Token) -> + case uaa_jwt_jwt:get_key_id(Token) of + {ok, KeyId} -> + case get_jwk(KeyId) of + {ok, JWK} -> + uaa_jwt_jwt:decode_and_verify(JWK, Token); + {error, _} = Err -> + Err + end; + {error, _} = Err -> + Err + end. + +-spec get_jwk(binary()) -> {ok, map()} | {error, term()}. +get_jwk(KeyId) -> + case signing_keys() of + undefined -> {error, signing_keys_not_configured}; + Keys -> + case maps:get(KeyId, Keys, undefined) of + undefined -> + {error, key_not_found}; + {Type, Value} -> + case Type of + json -> uaa_jwt_jwk:make_jwk(Value); + pem -> uaa_jwt_jwk:from_pem(Value); + pem_file -> uaa_jwt_jwk:from_pem_file(Value); + map -> uaa_jwt_jwk:make_jwk(Value); + _ -> {error, unknown_signing_key_type} + end + end + end. + +verify_signing_key(Type, Value) -> + Verified = case Type of + json -> uaa_jwt_jwk:make_jwk(Value); + pem -> uaa_jwt_jwk:from_pem(Value); + pem_file -> uaa_jwt_jwk:from_pem_file(Value); + map -> uaa_jwt_jwk:make_jwk(Value); + _ -> {error, unknown_signing_key_type} + end, + case Verified of + {ok, Key} -> + case jose_jwk:from(Key) of + #jose_jwk{} -> ok; + {error, Reason} -> {error, Reason} + end; + Err -> Err + end. + +signing_keys() -> + UaaEnv = application:get_env(?APP, key_config, []), + proplists:get_value(signing_keys, UaaEnv). + +-spec client_id(map()) -> binary() | undefined. +client_id(DecodedToken) -> + maps:get(<<"client_id">>, DecodedToken, undefined). + +-spec client_id(map(), any()) -> binary() | undefined. +client_id(DecodedToken, Default) -> + maps:get(<<"client_id">>, DecodedToken, Default). + +-spec sub(map()) -> binary() | undefined. +sub(DecodedToken) -> + maps:get(<<"sub">>, DecodedToken, undefined). + +-spec sub(map(), any()) -> binary() | undefined. +sub(DecodedToken, Default) -> + maps:get(<<"sub">>, DecodedToken, Default). diff --git a/deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt_jwk.erl b/deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt_jwk.erl new file mode 100644 index 0000000000..11d2819fb5 --- /dev/null +++ b/deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt_jwk.erl @@ -0,0 +1,84 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2020 VMware, Inc. or its affiliates. All rights reserved. +%% +-module(uaa_jwt_jwk). + +-export([make_jwk/1, from_pem/1, from_pem_file/1]). + +-include_lib("jose/include/jose_jwk.hrl"). + +-spec make_jwk(binary() | map()) -> {ok, #{binary() => binary()}} | {error, term()}. +make_jwk(Json) when is_binary(Json); is_list(Json) -> + JsonMap = jose:decode(iolist_to_binary(Json)), + make_jwk(JsonMap); + +make_jwk(JsonMap) when is_map(JsonMap) -> + case JsonMap of + #{<<"kty">> := <<"MAC">>, <<"value">> := _Value} -> + {ok, mac_to_oct(JsonMap)}; + #{<<"kty">> := <<"RSA">>, <<"n">> := _N, <<"e">> := _E} -> + {ok, fix_alg(JsonMap)}; + #{<<"kty">> := <<"oct">>, <<"k">> := _K} -> + {ok, fix_alg(JsonMap)}; + #{<<"kty">> := <<"OKP">>, <<"crv">> := _Crv, <<"x">> := _X} -> + {ok, fix_alg(JsonMap)}; + #{<<"kty">> := <<"EC">>} -> + {ok, fix_alg(JsonMap)}; + #{<<"kty">> := Kty} when Kty == <<"oct">>; + Kty == <<"MAC">>; + Kty == <<"RSA">>; + Kty == <<"OKP">>; + Kty == <<"EC">> -> + {error, {fields_missing_for_kty, Kty}}; + #{<<"kty">> := _Kty} -> + {error, unknown_kty}; + #{} -> + {error, no_kty} + end. + +from_pem(Pem) -> + case jose_jwk:from_pem(Pem) of + #jose_jwk{} = Jwk -> {ok, Jwk}; + Other -> + error_logger:warning_msg("Error parsing jwk from pem: ", [Other]), + {error, invalid_pem_string} + end. + +from_pem_file(FileName) -> + case filelib:is_file(FileName) of + false -> + {error, enoent}; + true -> + case jose_jwk:from_pem_file(FileName) of + #jose_jwk{} = Jwk -> {ok, Jwk}; + Other -> + error_logger:warning_msg("Error parsing jwk from pem file: ", [Other]), + {error, invalid_pem_file} + end + end. + +mac_to_oct(#{<<"kty">> := <<"MAC">>, <<"value">> := Value} = Key) -> + OktKey = maps:merge(Key, + #{<<"kty">> => <<"oct">>, + <<"k">> => base64url:encode(Value)}), + fix_alg(OktKey). + +fix_alg(#{<<"alg">> := Alg} = Key) -> + Algs = uaa_algs(), + case maps:get(Alg, Algs, undefined) of + undefined -> Key; + Val -> Key#{<<"alg">> := Val} + end; +fix_alg(#{} = Key) -> Key. + +uaa_algs() -> + UaaEnv = application:get_env(rabbitmq_auth_backend_oauth2, uaa_jwt_decoder, []), + DefaultAlgs = #{<<"HMACSHA256">> => <<"HS256">>, + <<"HMACSHA384">> => <<"HS384">>, + <<"HMACSHA512">> => <<"HS512">>, + <<"SHA256withRSA">> => <<"RS256">>, + <<"SHA512withRSA">> => <<"RS512">>}, + proplists:get_value(uaa_algs, UaaEnv, DefaultAlgs). diff --git a/deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt_jwt.erl b/deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt_jwt.erl new file mode 100644 index 0000000000..c2e41c5d52 --- /dev/null +++ b/deps/rabbitmq_auth_backend_oauth2/src/uaa_jwt_jwt.erl @@ -0,0 +1,48 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2020 VMware, Inc. or its affiliates. All rights reserved. +%% +-module(uaa_jwt_jwt). + +%% Transitional step until we can require Erlang/OTP 21 and +%% use the now recommended try/catch syntax for obtaining the stack trace. +-compile(nowarn_deprecated_function). + +-export([decode/1, decode_and_verify/2, get_key_id/1]). + +-include_lib("jose/include/jose_jwt.hrl"). +-include_lib("jose/include/jose_jws.hrl"). + +decode(Token) -> + try + #jose_jwt{fields = Fields} = jose_jwt:peek_payload(Token), + Fields + catch Type:Err:Stacktrace -> + {error, {invalid_token, Type, Err, Stacktrace}} + end. + +decode_and_verify(Jwk, Token) -> + case jose_jwt:verify(Jwk, Token) of + {true, #jose_jwt{fields = Fields}, _} -> {true, Fields}; + {false, #jose_jwt{fields = Fields}, _} -> {false, Fields} + end. + +get_key_id(Token) -> + try + case jose_jwt:peek_protected(Token) of + #jose_jws{fields = #{<<"kid">> := Kid}} -> {ok, Kid}; + #jose_jws{} -> get_default_key() + end + catch Type:Err:Stacktrace -> + {error, {invalid_token, Type, Err, Stacktrace}} + end. + + +get_default_key() -> + UaaEnv = application:get_env(rabbitmq_auth_backend_oauth2, key_config, []), + case proplists:get_value(default_key, UaaEnv, undefined) of + undefined -> {error, no_key}; + Val -> {ok, Val} + end. diff --git a/deps/rabbitmq_auth_backend_oauth2/src/wildcard.erl b/deps/rabbitmq_auth_backend_oauth2/src/wildcard.erl new file mode 100644 index 0000000000..01212901c6 --- /dev/null +++ b/deps/rabbitmq_auth_backend_oauth2/src/wildcard.erl @@ -0,0 +1,58 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2020 VMware, Inc. or its affiliates. All rights reserved. +%% + +-module(wildcard). + +-export([match/2]). + +-spec match(Subject :: binary(), Pattern :: binary()) -> boolean(). +match(Subject, Pattern) -> + case parse_pattern(Pattern) of + [First | Rest] -> + FirstSize = byte_size(First), + case Subject of + % If a pattern does not start with a wildcard, + % do exact matching in the beginning of the subject + <<First:FirstSize/binary, _/binary>> -> + scan(Subject, Rest, FirstSize, byte_size(Subject)); + _ -> false + end; + invalid -> false + end. + +-spec scan(Subject :: binary(), Pattern :: [binary()], + Pos :: integer(), Length :: integer()) -> boolean(). +% Pattern ends with a wildcard +scan(_Subject, [<<>>], _Pos, _Length) -> true; +% Pattern is complete. Subject scan is complete +scan(_Subject, [], Length, Length) -> true; +% No more pattern but subject scan is not complete +scan(_Subject, [], Pos, Length) when Pos =/= Length -> false; +% Subject scan is complete but there are more pattern elements +scan(_Subject, _NonEmpty, Length, Length) -> false; +% Skip duplicate wildcards +scan(Subject, [<<>> | Rest], Pos, Length) -> + scan(Subject, Rest, Pos, Length); +% Every other Part is after a wildcard +scan(Subject, [Part | Rest], Pos, Length) -> + PartSize = byte_size(Part), + case binary:match(Subject, Part, [{scope, {Pos, Length - Pos}}]) of + nomatch -> false; + {PartPos, PartSize} -> + NewPos = PartPos + PartSize, + scan(Subject, Rest, NewPos, Length) + end. + +-spec parse_pattern(binary()) -> [binary()] | invalid. +parse_pattern(Pattern) -> + Parts = binary:split(Pattern, <<"*">>, [global]), + try lists:map(fun(Part) -> cow_qs:urldecode(Part) end, Parts) + catch Type:Error -> + rabbit_log:warning("Invalid pattern ~p : ~p~n", + [Pattern, {Type, Error}]), + invalid + end. |