From 726e25d6aebae9c298742ac751222efb39ca973a Mon Sep 17 00:00:00 2001 From: Ilya Averyanov Date: Fri, 26 Nov 2021 16:12:49 +0300 Subject: [PATCH] chore(authn): add JWKS backend tests --- .../emqx_authn_jwks_connector.erl | 55 ++++++------ .../src/simple_authn/emqx_authn_jwt.erl | 23 ++--- apps/emqx_authn/test/emqx_authn_jwt_SUITE.erl | 90 +++++++++++++++++-- 3 files changed, 128 insertions(+), 40 deletions(-) diff --git a/apps/emqx_authn/src/simple_authn/emqx_authn_jwks_connector.erl b/apps/emqx_authn/src/simple_authn/emqx_authn_jwks_connector.erl index d8ceb7f40..3fc4bac13 100644 --- a/apps/emqx_authn/src/simple_authn/emqx_authn_jwks_connector.erl +++ b/apps/emqx_authn/src/simple_authn/emqx_authn_jwks_connector.erl @@ -20,6 +20,8 @@ -include_lib("emqx/include/logger.hrl"). -include_lib("jose/include/jose_jwk.hrl"). +-include_lib("snabbkaffe/include/snabbkaffe.hrl"). + -export([ start_link/1 , stop/1 @@ -66,9 +68,9 @@ init([Opts]) -> handle_call(get_cached_jwks, _From, #{jwks := Jwks} = State) -> {reply, {ok, Jwks}, State}; -handle_call({update, Opts}, _From, State) -> - State = handle_options(Opts), - {reply, ok, refresh_jwks(State)}; +handle_call({update, Opts}, _From, _State) -> + NewState = handle_options(Opts), + {reply, ok, refresh_jwks(NewState)}; handle_call(_Req, _From, State) -> {reply, ok, State}. @@ -91,25 +93,27 @@ handle_info({refresh_jwks, _TRef, refresh}, #{request_id := RequestID} = State) handle_info({http, {RequestID, Result}}, #{request_id := RequestID, endpoint := Endpoint} = State0) -> + ?tp(debug, jwks_endpoint_response, #{request_id => RequestID}), State1 = State0#{request_id := undefined}, - case Result of - {error, Reason} -> - ?SLOG(warning, #{msg => "failed_to_request_jwks_endpoint", - endpoint => Endpoint, - reason => Reason}), - State1; - {_StatusLine, _Headers, Body} -> - try - JWKS = jose_jwk:from(emqx_json:decode(Body, [return_maps])), - {_, JWKs} = JWKS#jose_jwk.keys, - State1#{jwks := JWKs} - catch _:_ -> - ?SLOG(warning, #{msg => "invalid_jwks_returned", - endpoint => Endpoint, - body => Body}), - State1 - end - end; + NewState = case Result of + {error, Reason} -> + ?SLOG(warning, #{msg => "failed_to_request_jwks_endpoint", + endpoint => Endpoint, + reason => Reason}), + State1; + {_StatusLine, _Headers, Body} -> + try + JWKS = jose_jwk:from(emqx_json:decode(Body, [return_maps])), + {_, JWKs} = JWKS#jose_jwk.keys, + State1#{jwks := JWKs} + catch _:_ -> + ?SLOG(warning, #{msg => "invalid_jwks_returned", + endpoint => Endpoint, + body => Body}), + State1 + end + end, + {noreply, NewState}; handle_info({http, {_, _}}, State) -> %% ignore @@ -147,17 +151,18 @@ refresh_jwks(#{endpoint := Endpoint, NState = case httpc:request(get, {Endpoint, [{"Accept", "application/json"}]}, HTTPOpts, [{body_format, binary}, {sync, false}, {receiver, self()}]) of {error, Reason} -> - ?SLOG(warning, #{msg => "failed_to_request_jwks_endpoint", - endpoint => Endpoint, - reason => Reason}), + ?tp(warning, jwks_endpoint_request_fail, #{endpoint => Endpoint, + http_opts => HTTPOpts, + reason => Reason}), State; {ok, RequestID} -> + ?tp(debug, jwks_endpoint_request_ok, #{request_id => RequestID}), State#{request_id := RequestID} end, ensure_expiry_timer(NState). ensure_expiry_timer(State = #{refresh_interval := Interval}) -> - State#{refresh_timer := emqx_misc:start_timer(timer:seconds(Interval), refresh_jwks)}. + State#{refresh_timer => emqx_misc:start_timer(timer:seconds(Interval), refresh_jwks)}. cancel_timer(State = #{refresh_timer := undefined}) -> State; diff --git a/apps/emqx_authn/src/simple_authn/emqx_authn_jwt.erl b/apps/emqx_authn/src/simple_authn/emqx_authn_jwt.erl index 7ec7eac6d..916911ffa 100644 --- a/apps/emqx_authn/src/simple_authn/emqx_authn_jwt.erl +++ b/apps/emqx_authn/src/simple_authn/emqx_authn_jwt.erl @@ -157,7 +157,7 @@ update(#{use_jwks := false} = Config, _State) -> update(#{use_jwks := true} = Config, #{jwk := Connector} = State) when is_pid(Connector) -> - ok = emqx_authn_jwks_connector:update(Connector, Config), + ok = emqx_authn_jwks_connector:update(Connector, connector_opts(Config)), case maps:get(verify_cliams, Config, undefined) of undefined -> {ok, State}; @@ -208,7 +208,7 @@ create2(#{use_jwks := false, JWK = jose_jwk:from_oct(Secret), {ok, #{jwk => JWK, verify_claims => VerifyClaims}} - end; + end; create2(#{use_jwks := false, algorithm := 'public-key', @@ -219,13 +219,8 @@ create2(#{use_jwks := false, verify_claims => VerifyClaims}}; create2(#{use_jwks := true, - verify_claims := VerifyClaims, - ssl := #{enable := Enable} = SSL} = Config) -> - SSLOpts = case Enable of - true -> maps:without([enable], SSL); - false -> #{} - end, - case emqx_authn_jwks_connector:start_link(Config#{ssl_opts => SSLOpts}) of + verify_claims := VerifyClaims} = Config) -> + case emqx_authn_jwks_connector:start_link(connector_opts(Config)) of {ok, Connector} -> {ok, #{jwk => Connector, verify_claims => VerifyClaims}}; @@ -233,6 +228,14 @@ create2(#{use_jwks := true, {error, Reason} end. +connector_opts(#{ssl := #{enable := Enable} = SSL} = Config) -> + SSLOpts = case Enable of + true -> maps:without([enable], SSL); + false -> #{} + end, + Config#{ssl_opts => SSLOpts}. + + may_decode_secret(false, Secret) -> Secret; may_decode_secret(true, Secret) -> try base64:decode(Secret) @@ -260,7 +263,7 @@ verify(JWS, [JWK | More], VerifyClaims) -> Claims = emqx_json:decode(Payload, [return_maps]), case verify_claims(Claims, VerifyClaims) of ok -> - {ok, #{is_superuser => maps:get(<<"is_superuser">>, Claims, false)}}; + {ok, emqx_authn_utils:is_superuser(Claims)}; {error, Reason} -> {error, Reason} end; diff --git a/apps/emqx_authn/test/emqx_authn_jwt_SUITE.erl b/apps/emqx_authn/test/emqx_authn_jwt_SUITE.erl index 54db0a3c5..fe730acb0 100644 --- a/apps/emqx_authn/test/emqx_authn_jwt_SUITE.erl +++ b/apps/emqx_authn/test/emqx_authn_jwt_SUITE.erl @@ -21,11 +21,16 @@ -include_lib("common_test/include/ct.hrl"). -include_lib("eunit/include/eunit.hrl"). +-include_lib("snabbkaffe/include/snabbkaffe.hrl"). -include("emqx_authn.hrl"). -define(AUTHN_ID, <<"mechanism:jwt">>). +-define(JWKS_PORT, 33333). +-define(JWKS_PATH, "/jwks.json"). + + all() -> emqx_common_test_helpers:all(?MODULE). @@ -37,7 +42,11 @@ end_per_suite(_) -> emqx_common_test_helpers:stop_apps([emqx_authn]), ok. -t_jwt_authenticator(_) -> +%%------------------------------------------------------------------------------ +%% Tests +%%------------------------------------------------------------------------------ + +t_jwt_authenticator_hmac_based(_) -> Secret = <<"abcdef">>, Config = #{mechanism => jwt, use_jwks => false, @@ -121,10 +130,9 @@ t_jwt_authenticator(_) -> ?assertEqual(ok, emqx_authn_jwt:destroy(State3)), ok. -t_jwt_authenticator2(_) -> - Dir = code:lib_dir(emqx_authn, test), - PublicKey = list_to_binary(filename:join([Dir, "data/public_key.pem"])), - PrivateKey = list_to_binary(filename:join([Dir, "data/private_key.pem"])), +t_jwt_authenticator_public_key(_) -> + PublicKey = test_rsa_key(public), + PrivateKey = test_rsa_key(private), Config = #{mechanism => jwt, use_jwks => false, algorithm => 'public-key', @@ -142,6 +150,78 @@ t_jwt_authenticator2(_) -> ?assertEqual(ok, emqx_authn_jwt:destroy(State)), ok. +t_jwks_renewal(_Config) -> + ok = emqx_authn_http_test_server:start(?JWKS_PORT, ?JWKS_PATH), + ok = emqx_authn_http_test_server:set_handler(fun jwks_handler/2), + + PrivateKey = test_rsa_key(private), + Payload = #{<<"username">> => <<"myuser">>}, + JWS = generate_jws('public-key', Payload, PrivateKey), + Credential = #{username => <<"myuser">>, + password => JWS}, + + BadConfig = #{mechanism => jwt, + algorithm => 'public-key', + ssl => #{enable => false}, + verify_claims => [], + + use_jwks => true, + endpoint => "http://127.0.0.1:" ++ integer_to_list(?JWKS_PORT + 1) ++ ?JWKS_PATH, + refresh_interval => 1000 + }, + + ok = snabbkaffe:start_trace(), + + {{ok, State0}, _} = ?wait_async_action( + emqx_authn_jwt:create(?AUTHN_ID, BadConfig), + #{?snk_kind := jwks_endpoint_response}, + 1000), + + ok = snabbkaffe:stop(), + + ?assertEqual(ignore, emqx_authn_jwt:authenticate(Credential, State0)), + ?assertEqual(ignore, emqx_authn_jwt:authenticate(Credential#{password => <<"badpassword">>}, State0)), + + GoodConfig = BadConfig#{endpoint => + "http://127.0.0.1:" ++ integer_to_list(?JWKS_PORT) ++ ?JWKS_PATH}, + + ok = snabbkaffe:start_trace(), + + {{ok, State1}, _} = ?wait_async_action( + emqx_authn_jwt:update(GoodConfig, State0), + #{?snk_kind := jwks_endpoint_response}, + 1000), + + ok = snabbkaffe:stop(), + + ?assertEqual({ok, #{is_superuser => false}}, emqx_authn_jwt:authenticate(Credential, State1)), + ?assertEqual(ignore, emqx_authn_jwt:authenticate(Credential#{password => <<"badpassword">>}, State1)), + + ?assertEqual(ok, emqx_authn_jwt:destroy(State1)), + ok = emqx_authn_http_test_server:stop(). + +%%------------------------------------------------------------------------------ +%% Helpers +%%------------------------------------------------------------------------------ + +jwks_handler(Req0, State) -> + JWK = jose_jwk:from_pem_file(test_rsa_key(public)), + JWKS = jose_jwk_set:to_map([JWK], #{}), + Req = cowboy_req:reply( + 200, + #{<<"content-type">> => <<"application/json">>}, + jiffy:encode(JWKS), + Req0), + {ok, Req, State}. + +test_rsa_key(public) -> + Dir = code:lib_dir(emqx_authn, test), + list_to_binary(filename:join([Dir, "data/public_key.pem"])); + +test_rsa_key(private) -> + Dir = code:lib_dir(emqx_authn, test), + list_to_binary(filename:join([Dir, "data/private_key.pem"])). + generate_jws('hmac-based', Payload, Secret) -> JWK = jose_jwk:from_oct(Secret), Header = #{ <<"alg">> => <<"HS256">>