From 512b4b9cbb9a244d2dfc9f192b2ef679fc41db88 Mon Sep 17 00:00:00 2001 From: firest Date: Wed, 19 Jun 2024 21:22:27 +0800 Subject: [PATCH 01/10] feat(sso): add OIDC support --- apps/emqx_dashboard_sso/rebar.config | 3 +- .../src/emqx_dashboard_sso.app.src | 3 +- .../src/emqx_dashboard_sso.erl | 3 +- .../src/emqx_dashboard_sso_oidc.erl | 158 ++++++++++++++++++ .../src/emqx_dashboard_sso_oidc_api.erl | 156 +++++++++++++++++ rel/i18n/emqx_dashboard_sso_oidc.hocon | 21 +++ rel/i18n/emqx_dashboard_sso_oidc_api.hocon | 6 + 7 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl create mode 100644 rel/i18n/emqx_dashboard_sso_oidc.hocon create mode 100644 rel/i18n/emqx_dashboard_sso_oidc_api.hocon diff --git a/apps/emqx_dashboard_sso/rebar.config b/apps/emqx_dashboard_sso/rebar.config index 90172af3d..070e1edb1 100644 --- a/apps/emqx_dashboard_sso/rebar.config +++ b/apps/emqx_dashboard_sso/rebar.config @@ -4,5 +4,6 @@ {deps, [ {emqx_ldap, {path, "../../apps/emqx_ldap"}}, {emqx_dashboard, {path, "../../apps/emqx_dashboard"}}, - {esaml, {git, "https://github.com/emqx/esaml", {tag, "v1.1.3"}}} + {esaml, {git, "https://github.com/emqx/esaml", {tag, "v1.1.3"}}}, + {oidcc, {git, "https://github.com/erlef/oidcc.git", {tag, "v3.2.0"}}} ]}. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src index 7f5e8f04a..ab40e3848 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src @@ -7,7 +7,8 @@ stdlib, emqx_dashboard, emqx_ldap, - esaml + esaml, + oidcc ]}, {mod, {emqx_dashboard_sso_app, []}}, {env, []}, diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl index 2750fc528..599a4ad8f 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl @@ -92,7 +92,8 @@ provider(Backend) -> backends() -> #{ ldap => emqx_dashboard_sso_ldap, - saml => emqx_dashboard_sso_saml + saml => emqx_dashboard_sso_saml, + oidc => emqx_dashboard_sso_oidc }. format(Args) -> diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl new file mode 100644 index 000000000..26e5a4d44 --- /dev/null +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl @@ -0,0 +1,158 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023-2024 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_sso_oidc). + +-include_lib("emqx_dashboard/include/emqx_dashboard.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("hocon/include/hoconsc.hrl"). + +-behaviour(emqx_dashboard_sso). + +-export([ + namespace/0, + fields/1, + desc/1 +]). + +-export([ + hocon_ref/0, + login_ref/0, + login/2, + create/1, + update/2, + destroy/1, + convert_certs/2 +]). + +-define(PROVIDER_SVR_NAME, sso_oidc_provider). +-define(RESPHEADERS, #{ + <<"cache-control">> => <<"no-cache">>, + <<"pragma">> => <<"no-cache">>, + <<"content-type">> => <<"text/plain">> +}). +-define(REDIRECT_BODY, <<"Redirecting...">>). + +%%------------------------------------------------------------------------------ +%% Hocon Schema +%%------------------------------------------------------------------------------ + +namespace() -> + "sso". + +hocon_ref() -> + hoconsc:ref(?MODULE, oidc). + +login_ref() -> + hoconsc:ref(?MODULE, login). + +fields(oidc) -> + emqx_dashboard_sso_schema:common_backend_schema([oidc]) ++ + [ + {issuer, + ?HOCON( + binary(), + #{desc => ?DESC(issuer), required => true} + )}, + {clientid, + ?HOCON( + binary(), + #{desc => ?DESC(clientid), required => true} + )}, + {secret, + ?HOCON( + binary(), + #{desc => ?DESC(secret), required => true} + )}, + {scopes, + ?HOCON( + ?ARRAY(binary()), + #{desc => ?DESC(scopes), default => [<<"openid">>]} + )}, + {name_var, + ?HOCON( + binary(), + #{desc => ?DESC(name_var), default => <<"${sub}">>} + )}, + {dashboard_addr, + ?HOCON(binary(), #{ + desc => ?DESC(dashboard_addr), + default => <<"http://127.0.0.1:18083">> + })} + ]; +fields(login) -> + [ + emqx_dashboard_sso_schema:backend_schema([oidc]) + ]. + +desc(oidc) -> + "OIDC"; +desc(_) -> + undefined. + +%%------------------------------------------------------------------------------ +%% APIs +%%------------------------------------------------------------------------------ + +create(#{issuer := Issuer, name_var := NameVar} = Config) -> + case + oidcc_provider_configuration_worker:start_link(#{ + issuer => Issuer, + name => {local, ?PROVIDER_SVR_NAME} + }) + of + {ok, Pid} -> + {ok, #{ + pid => Pid, + config => Config, + name_tokens => emqx_placeholder:preproc_tmpl(NameVar) + }}; + {error, _} = Error -> + Error + end. + +update(Config, State) -> + destroy(State), + create(Config). + +destroy(#{pid := Pid}) -> + _ = catch gen_server:stop(Pid), + ok. + +login( + _Req, + #{ + config := #{ + clientid := ClientId, + secret := Secret, + scopes := Scopes + } + } = State +) -> + case + oidcc:create_redirect_url( + ?PROVIDER_SVR_NAME, + ClientId, + Secret, + #{ + scopes => Scopes, + state => random_bin(), + nonce => random_bin(), + redirect_uri => emqx_dashboard_sso_oidc_api:make_callback_url(State) + } + ) + of + {ok, [Base, Delimiter, Params]} -> + RedirectUri = <>, + Redirect = {302, ?RESPHEADERS#{<<"location">> => RedirectUri}, ?REDIRECT_BODY}, + {redirect, Redirect}; + {error, _Reason} = Error -> + Error + end. + +convert_certs(_Dir, Conf) -> + Conf. + +random_bin() -> + emqx_utils_conv:bin(emqx_utils:gen_id(16)). diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl new file mode 100644 index 000000000..b57ec8fbe --- /dev/null +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl @@ -0,0 +1,156 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2023-2024 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_sso_oidc_api). + +-behaviour(minirest_api). + +-include_lib("hocon/include/hoconsc.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("emqx_dashboard/include/emqx_dashboard.hrl"). + +-import(hoconsc, [ + mk/2, + array/1, + enum/1, + ref/1 +]). + +-import(emqx_dashboard_sso_api, [login_meta/3]). + +-export([ + api_spec/0, + paths/0, + schema/1, + namespace/0 +]). + +-export([code_callback/2, make_callback_url/1]). + +-define(BAD_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD'). +-define(BACKEND_NOT_FOUND, 'BACKEND_NOT_FOUND'). +-define(TAGS, <<"Dashboard Single Sign-On">>). +-define(BACKEND, oidc). +-define(BASE_PATH, "/api/v5"). +-define(CALLBACK_PATH, "/sso/oidc/callback"). + +namespace() -> "dashboard_sso". + +api_spec() -> + emqx_dashboard_swagger:spec(?MODULE, #{check_schema => false, translate_body => false}). + +paths() -> + [ + ?CALLBACK_PATH + ]. + +%% Handles Authorization Code callback from the OP. +schema("/sso/oidc/callback") -> + #{ + 'operationId' => code_callback, + get => #{ + tags => [?TAGS], + desc => ?DESC(code_callback), + responses => #{ + 200 => emqx_dashboard_api:fields([token, version, license]), + 401 => response_schema(401), + 404 => response_schema(404) + }, + security => [] + } + }. + +%%-------------------------------------------------------------------- +%% API +%%-------------------------------------------------------------------- +code_callback(get, #{query_string := #{<<"code">> := Code}}) -> + case emqx_dashboard_sso_manager:lookup_state(?BACKEND) of + #{pid := Pid, config := #{clientid := ClientId, secret := Secret}} = State -> + case + oidcc:retrieve_token( + Code, + Pid, + ClientId, + Secret, + #{redirect_uri => make_callback_url(State)} + ) + of + {ok, Token} -> + retrieve_userinfo(Token, State); + {error, Reason} -> + {401, #{code => ?BAD_USERNAME_OR_PWD, message => reason_to_message(Reason)}} + end; + _ -> + {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}} + end. + +%%-------------------------------------------------------------------- +%% internal +%%-------------------------------------------------------------------- +retrieve_userinfo(Token, #{ + pid := Pid, + config := #{clientid := ClientId, secret := Secret}, + name_tokens := NameTks +}) -> + case + oidcc:retrieve_userinfo( + Token, + Pid, + ClientId, + Secret, + #{} + ) + of + {ok, UserInfo} -> + ?SLOG(debug, #{ + msg => "sso_oidc_login_user_info", + user_info => UserInfo + }), + Username = emqx_placeholder:proc_tmpl(NameTks, UserInfo), + case ensure_user_exists(Username) of + {ok, Role, DashboardToken} -> + ?SLOG(info, #{ + msg => "dashboard_sso_login_successful" + }), + {200, login_meta(Username, Role, DashboardToken)}; + {error, Reason} -> + ?SLOG(info, #{ + msg => "dashboard_sso_login_failed", + reason => emqx_utils:redact(Reason) + }), + {401, #{code => ?BAD_USERNAME_OR_PWD, message => <<"Auth failed">>}} + end; + {error, Reason} -> + {401, #{code => ?BAD_USERNAME_OR_PWD, message => reason_to_message(Reason)}} + end. + +response_schema(401) -> + emqx_dashboard_swagger:error_codes([?BAD_USERNAME_OR_PWD], ?DESC(login_failed401)); +response_schema(404) -> + emqx_dashboard_swagger:error_codes([?BACKEND_NOT_FOUND], ?DESC(backend_not_found)). + +reason_to_message(Bin) when is_binary(Bin) -> + Bin; +reason_to_message(Term) -> + erlang:iolist_to_binary(io_lib:format("~p", [Term])). + +ensure_user_exists(<<>>) -> + {error, <<"Username can not be empty">>}; +ensure_user_exists(<<"undefined">>) -> + {error, <<"Username can not be undefined">>}; +ensure_user_exists(Username) -> + case emqx_dashboard_admin:lookup_user(?BACKEND, Username) of + [User] -> + emqx_dashboard_token:sign(User, <<>>); + [] -> + case emqx_dashboard_admin:add_sso_user(?BACKEND, Username, ?ROLE_VIEWER, <<>>) of + {ok, _} -> + ensure_user_exists(Username); + Error -> + Error + end + end. + +make_callback_url(#{config := #{dashboard_addr := Addr}}) -> + list_to_binary(binary_to_list(Addr) ++ ?BASE_PATH ++ ?CALLBACK_PATH). diff --git a/rel/i18n/emqx_dashboard_sso_oidc.hocon b/rel/i18n/emqx_dashboard_sso_oidc.hocon new file mode 100644 index 000000000..da9a7ec43 --- /dev/null +++ b/rel/i18n/emqx_dashboard_sso_oidc.hocon @@ -0,0 +1,21 @@ +emqx_dashboard_sso_oidc { + +issuer.desc: +"""The URL of the OIDC issuer.""" + +clientid.desc: +"""The clientId for this backend.""" + +secret.desc: +"""The client secret.""" + +scopes.desc: +"""The scopes, its default value is `["openid"]`.""" + +name_var.desc: +"""A template to map OIDC user information to a DashBoard name, its default value is `${sub}`.""" + +dashboard_addr.desc: +"""The address of the EMQX Dashboard.""" + +} diff --git a/rel/i18n/emqx_dashboard_sso_oidc_api.hocon b/rel/i18n/emqx_dashboard_sso_oidc_api.hocon new file mode 100644 index 000000000..b164d3db4 --- /dev/null +++ b/rel/i18n/emqx_dashboard_sso_oidc_api.hocon @@ -0,0 +1,6 @@ +emqx_dashboard_sso_oidc_api { + +code_callback.desc: +"""The callback path for the OIDC authorization server..""" + +} From 5e2693c9b48665b6c0b50fe21e1ca65291e55a2d Mon Sep 17 00:00:00 2001 From: firest Date: Thu, 20 Jun 2024 19:39:02 +0800 Subject: [PATCH 02/10] feat(oidc): implement session management --- .../src/emqx_dashboard_sso_manager.erl | 10 +- .../src/emqx_dashboard_sso_oidc.erl | 50 +++--- .../src/emqx_dashboard_sso_oidc_api.erl | 139 +++++++++------- .../src/emqx_dashboard_sso_oidc_session.erl | 156 ++++++++++++++++++ .../src/emqx_dashboard_sso_sup.erl | 13 +- rel/i18n/emqx_dashboard_sso_oidc.hocon | 3 + 6 files changed, 289 insertions(+), 82 deletions(-) create mode 100644 apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_session.erl diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl index 20289c2b3..c1d450ef2 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl @@ -17,6 +17,7 @@ handle_call/3, handle_cast/2, handle_info/2, + handle_continue/2, terminate/2, code_change/3, format_status/2 @@ -154,8 +155,7 @@ init([]) -> {read_concurrency, true} ] ), - start_backend_services(), - {ok, #{}}. + {ok, #{}, {continue, start_backend_services}}. handle_call(_Request, _From, State) -> Reply = ok, @@ -167,6 +167,12 @@ handle_cast(_Request, State) -> handle_info(_Info, State) -> {noreply, State}. +handle_continue(start_backend_services, State) -> + start_backend_services(), + {noreply, State}; +handle_continue(_Info, State) -> + {noreply, State}. + terminate(_Reason, _State) -> remove_handler(), ok. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl index 26e5a4d44..46062c893 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl @@ -26,7 +26,7 @@ convert_certs/2 ]). --define(PROVIDER_SVR_NAME, sso_oidc_provider). +-define(PROVIDER_SVR_NAME, ?MODULE). -define(RESPHEADERS, #{ <<"cache-control">> => <<"no-cache">>, <<"pragma">> => <<"no-cache">>, @@ -79,6 +79,11 @@ fields(oidc) -> ?HOCON(binary(), #{ desc => ?DESC(dashboard_addr), default => <<"http://127.0.0.1:18083">> + })}, + {session_expiry, + ?HOCON(emqx_schema:timeout_duration_ms(), #{ + desc => ?DESC(session_expiry), + default => <<"30s">> })} ]; fields(login) -> @@ -95,30 +100,32 @@ desc(_) -> %% APIs %%------------------------------------------------------------------------------ -create(#{issuer := Issuer, name_var := NameVar} = Config) -> +create(#{name_var := NameVar} = Config) -> case - oidcc_provider_configuration_worker:start_link(#{ - issuer => Issuer, - name => {local, ?PROVIDER_SVR_NAME} - }) + emqx_dashboard_sso_oidc_session:start( + ?PROVIDER_SVR_NAME, + Config + ) of - {ok, Pid} -> + {error, _} = Error -> + Error; + _ -> + %% Note: the oidcc maintains an ETS with the same name of the provider gen_server, + %% we should use this name in each API calls not the PID, + %% or it would backoff to sync calls to the gen_server {ok, #{ - pid => Pid, + name => ?PROVIDER_SVR_NAME, config => Config, name_tokens => emqx_placeholder:preproc_tmpl(NameVar) - }}; - {error, _} = Error -> - Error + }} end. update(Config, State) -> destroy(State), create(Config). -destroy(#{pid := Pid}) -> - _ = catch gen_server:stop(Pid), - ok. +destroy(_) -> + emqx_dashboard_sso_oidc_session:stop(). login( _Req, @@ -128,8 +135,12 @@ login( secret := Secret, scopes := Scopes } - } = State + } = Cfg ) -> + Nonce = emqx_dashboard_sso_oidc_session:random_bin(), + Data = #{nonce => Nonce}, + + State = emqx_dashboard_sso_oidc_session:new(Data), case oidcc:create_redirect_url( ?PROVIDER_SVR_NAME, @@ -137,9 +148,9 @@ login( Secret, #{ scopes => Scopes, - state => random_bin(), - nonce => random_bin(), - redirect_uri => emqx_dashboard_sso_oidc_api:make_callback_url(State) + state => State, + nonce => Nonce, + redirect_uri => emqx_dashboard_sso_oidc_api:make_callback_url(Cfg) } ) of @@ -153,6 +164,3 @@ login( convert_certs(_Dir, Conf) -> Conf. - -random_bin() -> - emqx_utils_conv:bin(emqx_utils:gen_id(16)). diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl index b57ec8fbe..a2b795aea 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl @@ -64,66 +64,26 @@ schema("/sso/oidc/callback") -> %%-------------------------------------------------------------------- %% API %%-------------------------------------------------------------------- -code_callback(get, #{query_string := #{<<"code">> := Code}}) -> - case emqx_dashboard_sso_manager:lookup_state(?BACKEND) of - #{pid := Pid, config := #{clientid := ClientId, secret := Secret}} = State -> - case - oidcc:retrieve_token( - Code, - Pid, - ClientId, - Secret, - #{redirect_uri => make_callback_url(State)} - ) - of - {ok, Token} -> - retrieve_userinfo(Token, State); - {error, Reason} -> - {401, #{code => ?BAD_USERNAME_OR_PWD, message => reason_to_message(Reason)}} - end; - _ -> - {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}} +code_callback(get, #{query_string := QS}) -> + case ensure_sso_state(QS) of + {ok, Username, Role, DashboardToken} -> + ?SLOG(info, #{ + msg => "dashboard_sso_login_successful" + }), + {200, login_meta(Username, Role, DashboardToken)}; + {error, invalid_backend} -> + {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}}; + {error, Reason} -> + ?SLOG(info, #{ + msg => "dashboard_sso_login_failed", + reason => emqx_utils:redact(Reason) + }), + {401, #{code => ?BAD_USERNAME_OR_PWD, message => reason_to_message(Reason)}} end. %%-------------------------------------------------------------------- %% internal %%-------------------------------------------------------------------- -retrieve_userinfo(Token, #{ - pid := Pid, - config := #{clientid := ClientId, secret := Secret}, - name_tokens := NameTks -}) -> - case - oidcc:retrieve_userinfo( - Token, - Pid, - ClientId, - Secret, - #{} - ) - of - {ok, UserInfo} -> - ?SLOG(debug, #{ - msg => "sso_oidc_login_user_info", - user_info => UserInfo - }), - Username = emqx_placeholder:proc_tmpl(NameTks, UserInfo), - case ensure_user_exists(Username) of - {ok, Role, DashboardToken} -> - ?SLOG(info, #{ - msg => "dashboard_sso_login_successful" - }), - {200, login_meta(Username, Role, DashboardToken)}; - {error, Reason} -> - ?SLOG(info, #{ - msg => "dashboard_sso_login_failed", - reason => emqx_utils:redact(Reason) - }), - {401, #{code => ?BAD_USERNAME_OR_PWD, message => <<"Auth failed">>}} - end; - {error, Reason} -> - {401, #{code => ?BAD_USERNAME_OR_PWD, message => reason_to_message(Reason)}} - end. response_schema(401) -> emqx_dashboard_swagger:error_codes([?BAD_USERNAME_OR_PWD], ?DESC(login_failed401)); @@ -135,6 +95,68 @@ reason_to_message(Bin) when is_binary(Bin) -> reason_to_message(Term) -> erlang:iolist_to_binary(io_lib:format("~p", [Term])). +ensure_sso_state(QS) -> + case emqx_dashboard_sso_manager:lookup_state(?BACKEND) of + undefined -> + {error, invalid_backend}; + Cfg -> + ensure_oidc_state(QS, Cfg) + end. + +ensure_oidc_state(#{<<"state">> := State} = QS, Cfg) -> + case emqx_dashboard_sso_oidc_session:lookup(State) of + {ok, Data} -> + emqx_dashboard_sso_oidc_session:delete(State), + retrieve_token(QS, Cfg, Data); + _ -> + {error, session_not_exists} + end. + +retrieve_token( + #{<<"code">> := Code}, + #{name := Name, config := #{clientid := ClientId, secret := Secret}} = Cfg, + #{nonce := Nonce} = _Data +) -> + case + oidcc:retrieve_token( + Code, + Name, + ClientId, + Secret, + #{redirect_uri => make_callback_url(Cfg), nonce => Nonce} + ) + of + {ok, Token} -> + retrieve_userinfo(Token, Cfg); + {error, _Reason} = Error -> + Error + end. + +retrieve_userinfo(Token, #{ + name := Name, + config := #{clientid := ClientId, secret := Secret}, + name_tokens := NameTks +}) -> + case + oidcc:retrieve_userinfo( + Token, + Name, + ClientId, + Secret, + #{} + ) + of + {ok, UserInfo} -> + ?SLOG(debug, #{ + msg => "sso_oidc_login_user_info", + user_info => UserInfo + }), + Username = emqx_placeholder:proc_tmpl(NameTks, UserInfo), + ensure_user_exists(Username); + {error, _Reason} = Error -> + Error + end. + ensure_user_exists(<<>>) -> {error, <<"Username can not be empty">>}; ensure_user_exists(<<"undefined">>) -> @@ -142,7 +164,12 @@ ensure_user_exists(<<"undefined">>) -> ensure_user_exists(Username) -> case emqx_dashboard_admin:lookup_user(?BACKEND, Username) of [User] -> - emqx_dashboard_token:sign(User, <<>>); + case emqx_dashboard_token:sign(User, <<>>) of + {ok, Role, Token} -> + {ok, Username, Role, Token}; + Error -> + Error + end; [] -> case emqx_dashboard_admin:add_sso_user(?BACKEND, Username, ?ROLE_VIEWER, <<>>) of {ok, _} -> diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_session.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_session.erl new file mode 100644 index 000000000..86ee1050a --- /dev/null +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_session.erl @@ -0,0 +1,156 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_sso_oidc_session). + +-behaviour(gen_server). + +-include_lib("emqx/include/logger.hrl"). +-include_lib("stdlib/include/ms_transform.hrl"). + +%% API +-export([start_link/1, start/2, stop/0]). + +%% gen_server callbacks +-export([ + init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2, + code_change/3, + format_status/2 +]). + +-export([new/1, delete/1, lookup/1, random_bin/0, random_bin/1]). + +-define(TAB, ?MODULE). + +-record(?TAB, { + state :: binary(), + created_at :: non_neg_integer(), + data :: map() +}). + +-define(DEFAULT_RANDOM_LEN, 32). +-define(NOW, erlang:system_time(millisecond)). + +%%------------------------------------------------------------------------------ +%% API +%%------------------------------------------------------------------------------ +start_link(Cfg) -> + gen_server:start_link({local, ?MODULE}, ?MODULE, Cfg, []). + +start(Name, #{issuer := Issuer, session_expiry := SessionExpiry}) -> + case + emqx_dashboard_sso_sup:start_child( + oidcc_provider_configuration_worker, + [ + #{ + issuer => Issuer, + name => {local, Name} + } + ] + ) + of + {error, _} = Error -> + Error; + _ -> + emqx_dashboard_sso_sup:start_child(?MODULE, [SessionExpiry]) + end. + +stop() -> + _ = emqx_dashboard_sso_sup:stop_child(oidcc_provider_configuration_worker), + _ = emqx_dashboard_sso_sup:stop_child(?MODULE), + ok. + +new(Data) -> + State = new_state(), + ets:insert( + ?TAB, + #?TAB{ + state = State, + created_at = ?NOW, + data = Data + } + ), + State. + +delete(State) -> + ets:delete(?TAB, State). + +lookup(State) -> + case ets:lookup(?TAB, State) of + [#?TAB{data = Data}] -> + {ok, Data}; + _ -> + undefined + end. + +random_bin() -> + random_bin(?DEFAULT_RANDOM_LEN). + +random_bin(Len) -> + emqx_utils_conv:bin(emqx_utils:gen_id(Len)). + +%%------------------------------------------------------------------------------ +%% gen_server callbacks +%%------------------------------------------------------------------------------ +init(SessionExpiry) -> + process_flag(trap_exit, true), + emqx_utils_ets:new( + ?TAB, + [ + ordered_set, + public, + named_table, + {keypos, #?TAB.state}, + {read_concurrency, true} + ] + ), + State = #{session_expiry => SessionExpiry}, + tick_session_expiry(State), + {ok, State}. + +handle_call(_Request, _From, State) -> + Reply = ok, + {reply, Reply, State}. + +handle_cast(_Request, State) -> + {noreply, State}. + +handle_info(tick_session_expiry, #{session_expiry := SessionExpiry} = State) -> + Now = ?NOW, + Spec = ets:fun2ms(fun(#?TAB{created_at = CreatedAt}) -> + Now - CreatedAt >= SessionExpiry + end), + _ = ets:select_delete(?TAB, Spec), + tick_session_expiry(State), + {noreply, State}; +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, _State) -> + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +format_status(_Opt, Status) -> + Status. + +%%------------------------------------------------------------------------------ +%% Internal functions +%%------------------------------------------------------------------------------ +new_state() -> + State = random_bin(), + case ets:lookup(?TAB, State) of + [] -> + State; + _ -> + new_state() + end. + +tick_session_expiry(#{session_expiry := SessionExpiry}) -> + erlang:send_after(SessionExpiry, self(), tick_session_expiry). diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl index 9d2bbc365..6336965fd 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl @@ -6,17 +6,24 @@ -behaviour(supervisor). --export([start_link/0]). +-export([start_link/0, start_child/2, stop_child/1]). -export([init/1]). --define(CHILD(I, ShutDown), {I, {I, start_link, []}, permanent, ShutDown, worker, [I]}). +-define(CHILD(I, Args), {I, {I, start_link, Args}, permanent, 5000, worker, [I]}). +-define(CHILD(I), ?CHILD(I, [])). start_link() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []). +start_child(Mod, Args) -> + supervisor:start_child(?MODULE, ?CHILD(Mod, Args)). + +stop_child(Mod) -> + supervisor:terminate_child(?MODULE, Mod). + init([]) -> {ok, {{one_for_one, 5, 100}, [ - ?CHILD(emqx_dashboard_sso_manager, 5000) + ?CHILD(emqx_dashboard_sso_manager) ]}}. diff --git a/rel/i18n/emqx_dashboard_sso_oidc.hocon b/rel/i18n/emqx_dashboard_sso_oidc.hocon index da9a7ec43..797829317 100644 --- a/rel/i18n/emqx_dashboard_sso_oidc.hocon +++ b/rel/i18n/emqx_dashboard_sso_oidc.hocon @@ -18,4 +18,7 @@ name_var.desc: dashboard_addr.desc: """The address of the EMQX Dashboard.""" +session_expiry.desc: +"""The valid time span for an OIDC `state`, the default is `30s`, if the code response returned by the authorization server exceeds this time span, it will be treated as invalid.""" + } From 9c0df3c0a81e0bcf0c317fdd5c920fc83d58add8 Mon Sep 17 00:00:00 2001 From: firest Date: Thu, 20 Jun 2024 22:57:00 +0800 Subject: [PATCH 03/10] feat(oidc): support the PKCE extension --- .../src/emqx_dashboard_sso_oidc.erl | 32 ++++++++++++++----- .../src/emqx_dashboard_sso_oidc_api.erl | 4 +-- rel/i18n/emqx_dashboard_sso_oidc.hocon | 3 ++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl index 46062c893..3d83711db 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl @@ -33,6 +33,7 @@ <<"content-type">> => <<"text/plain">> }). -define(REDIRECT_BODY, <<"Redirecting...">>). +-define(PKCE_VERIFIER_LEN, 60). %%------------------------------------------------------------------------------ %% Hocon Schema @@ -84,6 +85,11 @@ fields(oidc) -> ?HOCON(emqx_schema:timeout_duration_ms(), #{ desc => ?DESC(session_expiry), default => <<"30s">> + })}, + {require_pkce, + ?HOCON(boolean(), #{ + desc => ?DESC(require_pkce), + default => false })} ]; fields(login) -> @@ -133,25 +139,27 @@ login( config := #{ clientid := ClientId, secret := Secret, - scopes := Scopes + scopes := Scopes, + require_pkce := RequirePKCE } } = Cfg ) -> Nonce = emqx_dashboard_sso_oidc_session:random_bin(), - Data = #{nonce => Nonce}, + Opts = maybe_require_pkce(RequirePKCE, #{ + scopes => Scopes, + nonce => Nonce, + redirect_uri => emqx_dashboard_sso_oidc_api:make_callback_url(Cfg) + }), + Data = maps:with([nonce, require_pkce, pkce_verifier], Opts), State = emqx_dashboard_sso_oidc_session:new(Data), + case oidcc:create_redirect_url( ?PROVIDER_SVR_NAME, ClientId, Secret, - #{ - scopes => Scopes, - state => State, - nonce => Nonce, - redirect_uri => emqx_dashboard_sso_oidc_api:make_callback_url(Cfg) - } + Opts#{state => State} ) of {ok, [Base, Delimiter, Params]} -> @@ -164,3 +172,11 @@ login( convert_certs(_Dir, Conf) -> Conf. + +maybe_require_pkce(false, Opts) -> + Opts; +maybe_require_pkce(true, Opts) -> + Opts#{ + require_pkce => true, + pkce_verifier => emqx_dashboard_sso_oidc_session:random_bin(?PKCE_VERIFIER_LEN) + }. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl index a2b795aea..a886e7777 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl @@ -115,7 +115,7 @@ ensure_oidc_state(#{<<"state">> := State} = QS, Cfg) -> retrieve_token( #{<<"code">> := Code}, #{name := Name, config := #{clientid := ClientId, secret := Secret}} = Cfg, - #{nonce := Nonce} = _Data + Data ) -> case oidcc:retrieve_token( @@ -123,7 +123,7 @@ retrieve_token( Name, ClientId, Secret, - #{redirect_uri => make_callback_url(Cfg), nonce => Nonce} + Data#{redirect_uri => make_callback_url(Cfg)} ) of {ok, Token} -> diff --git a/rel/i18n/emqx_dashboard_sso_oidc.hocon b/rel/i18n/emqx_dashboard_sso_oidc.hocon index 797829317..cacec9617 100644 --- a/rel/i18n/emqx_dashboard_sso_oidc.hocon +++ b/rel/i18n/emqx_dashboard_sso_oidc.hocon @@ -21,4 +21,7 @@ dashboard_addr.desc: session_expiry.desc: """The valid time span for an OIDC `state`, the default is `30s`, if the code response returned by the authorization server exceeds this time span, it will be treated as invalid.""" +require_pkce.desc: +"""Whether to require PKCE when getting the token.""" + } From ddb197951ee75f182861da561fbdfd5b41c65904 Mon Sep 17 00:00:00 2001 From: firest Date: Fri, 21 Jun 2024 20:25:25 +0800 Subject: [PATCH 04/10] feat(oidc): implement JWKS, private_key_jwt, DPoP --- .../src/emqx_dashboard_sso_oidc.erl | 79 ++++++++++++++++++- .../src/emqx_dashboard_sso_oidc_api.erl | 14 +++- .../src/emqx_dashboard_sso_sup.erl | 10 ++- rel/i18n/emqx_dashboard_sso_oidc.hocon | 9 +++ 4 files changed, 102 insertions(+), 10 deletions(-) diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl index 3d83711db..d36d08cb7 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl @@ -90,8 +90,27 @@ fields(oidc) -> ?HOCON(boolean(), #{ desc => ?DESC(require_pkce), default => false + })}, + {client_jwks, + %% TODO: add url JWKS + ?HOCON(?UNION([none, ?R_REF(client_file_jwks)]), #{ + desc => ?DESC(client_jwks), + default => none })} ]; +fields(client_file_jwks) -> + [ + {type, + ?HOCON(?ENUM([file]), #{ + desc => ?DESC(client_file_jwks_type), + required => true + })}, + {file, + ?HOCON(binary(), #{ + desc => ?DESC(client_file_jwks_file), + required => true + })} + ]; fields(login) -> [ emqx_dashboard_sso_schema:backend_schema([oidc]) @@ -119,9 +138,11 @@ create(#{name_var := NameVar} = Config) -> %% Note: the oidcc maintains an ETS with the same name of the provider gen_server, %% we should use this name in each API calls not the PID, %% or it would backoff to sync calls to the gen_server + ClientJwks = init_client_jwks(Config), {ok, #{ name => ?PROVIDER_SVR_NAME, config => Config, + client_jwks => ClientJwks, name_tokens => emqx_placeholder:preproc_tmpl(NameVar) }} end. @@ -130,12 +151,14 @@ update(Config, State) -> destroy(State), create(Config). -destroy(_) -> - emqx_dashboard_sso_oidc_session:stop(). +destroy(State) -> + emqx_dashboard_sso_oidc_session:stop(), + try_delete_jwks_file(State). login( _Req, #{ + client_jwks := ClientJwks, config := #{ clientid := ClientId, secret := Secret, @@ -159,7 +182,7 @@ login( ?PROVIDER_SVR_NAME, ClientId, Secret, - Opts#{state => State} + Opts#{state => State, client_jwks => ClientJwks} ) of {ok, [Base, Delimiter, Params]} -> @@ -170,9 +193,49 @@ login( Error end. +convert_certs( + Dir, + #{ + <<"client_jwks">> := #{ + <<"type">> := file, + <<"file">> := Content + } = Jwks + } = Conf +) -> + case save_jwks_file(Dir, Content) of + {ok, Path} -> + Conf#{<<"client_jwks">> := Jwks#{<<"file">> := Path}}; + {error, Reason} -> + ?SLOG(error, #{msg => "failed_to_save_client_jwks", reason => Reason}), + throw("Failed to save client jwks") + end; convert_certs(_Dir, Conf) -> Conf. +%%------------------------------------------------------------------------------ +%% Internal functions +%%------------------------------------------------------------------------------ + +save_jwks_file(Dir, Content) -> + Path = filename:join([emqx_tls_lib:pem_dir(Dir), "client_jwks"]), + case filelib:ensure_dir(Path) of + ok -> + case file:write_file(Path, Content) of + ok -> + {ok, Path}; + {error, Reason} -> + {error, #{failed_to_write_file => Reason, file_path => Path}} + end; + {error, Reason} -> + {error, #{failed_to_create_dir_for => Path, reason => Reason}} + end. + +try_delete_jwks_file(#{config := #{client_jwks := #{type := file, file := File}}}) -> + _ = file:delete(File), + ok; +try_delete_jwks_file(_) -> + ok. + maybe_require_pkce(false, Opts) -> Opts; maybe_require_pkce(true, Opts) -> @@ -180,3 +243,13 @@ maybe_require_pkce(true, Opts) -> require_pkce => true, pkce_verifier => emqx_dashboard_sso_oidc_session:random_bin(?PKCE_VERIFIER_LEN) }. + +init_client_jwks(#{client_jwks := #{type := file, file := File}}) -> + case jose_jwk:from_file(File) of + {error, _} -> + none; + Jwks -> + Jwks + end; +init_client_jwks(_) -> + none. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl index a886e7777..a1008f29d 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl @@ -114,7 +114,11 @@ ensure_oidc_state(#{<<"state">> := State} = QS, Cfg) -> retrieve_token( #{<<"code">> := Code}, - #{name := Name, config := #{clientid := ClientId, secret := Secret}} = Cfg, + #{ + name := Name, + client_jwks := ClientJwks, + config := #{clientid := ClientId, secret := Secret} + } = Cfg, Data ) -> case @@ -123,7 +127,10 @@ retrieve_token( Name, ClientId, Secret, - Data#{redirect_uri => make_callback_url(Cfg)} + Data#{ + redirect_uri => make_callback_url(Cfg), + client_jwks => ClientJwks + } ) of {ok, Token} -> @@ -134,6 +141,7 @@ retrieve_token( retrieve_userinfo(Token, #{ name := Name, + client_jwks := ClientJwks, config := #{clientid := ClientId, secret := Secret}, name_tokens := NameTks }) -> @@ -143,7 +151,7 @@ retrieve_userinfo(Token, #{ Name, ClientId, Secret, - #{} + #{client_jwks => ClientJwks} ) of {ok, UserInfo} -> diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl index 6336965fd..f82d8f749 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_sup.erl @@ -10,17 +10,19 @@ -export([init/1]). --define(CHILD(I, Args), {I, {I, start_link, Args}, permanent, 5000, worker, [I]}). --define(CHILD(I), ?CHILD(I, [])). +-define(CHILD(I, Args, Restart), {I, {I, start_link, Args}, Restart, 5000, worker, [I]}). +-define(CHILD(I), ?CHILD(I, [], permanent)). start_link() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []). start_child(Mod, Args) -> - supervisor:start_child(?MODULE, ?CHILD(Mod, Args)). + supervisor:start_child(?MODULE, ?CHILD(Mod, Args, transient)). stop_child(Mod) -> - supervisor:terminate_child(?MODULE, Mod). + _ = supervisor:terminate_child(?MODULE, Mod), + _ = supervisor:delete_child(?MODULE, Mod), + ok. init([]) -> {ok, diff --git a/rel/i18n/emqx_dashboard_sso_oidc.hocon b/rel/i18n/emqx_dashboard_sso_oidc.hocon index cacec9617..a9a2f61c4 100644 --- a/rel/i18n/emqx_dashboard_sso_oidc.hocon +++ b/rel/i18n/emqx_dashboard_sso_oidc.hocon @@ -24,4 +24,13 @@ session_expiry.desc: require_pkce.desc: """Whether to require PKCE when getting the token.""" +client_jwks.desc: +"""Set JWK or JWKS here to enable the `private_key_jwt` authorization or the `DPoP` extension.""" + +client_file_jwks_type.desc: +"""The JWKS source type.""" + +client_file_jwks_file.desc: +"""The content of the JWKS.""" + } From 892420e2c6211b8c2d2c8d86f3a14959aabf9fed Mon Sep 17 00:00:00 2001 From: firest Date: Fri, 21 Jun 2024 22:38:20 +0800 Subject: [PATCH 05/10] feat(oidc): be more compatible with okta --- apps/emqx_dashboard_sso/rebar.config | 2 +- .../src/emqx_dashboard_sso_oidc.erl | 42 ++++++++++++++++++- .../src/emqx_dashboard_sso_oidc_api.erl | 10 ++++- rel/i18n/emqx_dashboard_sso_oidc.hocon | 9 ++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/apps/emqx_dashboard_sso/rebar.config b/apps/emqx_dashboard_sso/rebar.config index 070e1edb1..e9a52c56d 100644 --- a/apps/emqx_dashboard_sso/rebar.config +++ b/apps/emqx_dashboard_sso/rebar.config @@ -5,5 +5,5 @@ {emqx_ldap, {path, "../../apps/emqx_ldap"}}, {emqx_dashboard, {path, "../../apps/emqx_dashboard"}}, {esaml, {git, "https://github.com/emqx/esaml", {tag, "v1.1.3"}}}, - {oidcc, {git, "https://github.com/erlef/oidcc.git", {tag, "v3.2.0"}}} + {oidcc, {git, "https://github.com/emqx/oidcc.git", {branch, "ev3.2.0"}}} ]}. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl index d36d08cb7..f904230c7 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl @@ -91,6 +91,38 @@ fields(oidc) -> desc => ?DESC(require_pkce), default => false })}, + {preferred_auth_methods, + ?HOCON( + ?ARRAY( + ?ENUM([ + private_key_jwt, + client_secret_jwt, + client_secret_post, + client_secret_basic, + none + ]) + ), + #{ + desc => ?DESC(preferred_auth_methods), + default => [ + client_secret_post, + client_secret_basic, + none + ] + } + )}, + {provider, + ?HOCON(?ENUM([okta, generic]), #{ + mapping => "oidcc.provider", + desc => ?DESC(provider), + default => generic + })}, + {fallback_methods, + ?HOCON(?ARRAY(binary()), #{ + mapping => "oidcc.fallback_methods", + desc => ?DESC(fallback_methods), + default => [<<"RS256">>] + })}, {client_jwks, %% TODO: add url JWKS ?HOCON(?UNION([none, ?R_REF(client_file_jwks)]), #{ @@ -155,6 +187,7 @@ destroy(State) -> emqx_dashboard_sso_oidc_session:stop(), try_delete_jwks_file(State). +-dialyzer({nowarn_function, login/2}). login( _Req, #{ @@ -163,7 +196,8 @@ login( clientid := ClientId, secret := Secret, scopes := Scopes, - require_pkce := RequirePKCE + require_pkce := RequirePKCE, + preferred_auth_methods := AuthMethods } } = Cfg ) -> @@ -182,7 +216,11 @@ login( ?PROVIDER_SVR_NAME, ClientId, Secret, - Opts#{state => State, client_jwks => ClientJwks} + Opts#{ + state => State, + client_jwks => ClientJwks, + preferred_auth_methods => AuthMethods + } ) of {ok, [Base, Delimiter, Params]} -> diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl index a1008f29d..3b5c9f5d8 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl @@ -117,7 +117,11 @@ retrieve_token( #{ name := Name, client_jwks := ClientJwks, - config := #{clientid := ClientId, secret := Secret} + config := #{ + clientid := ClientId, + secret := Secret, + preferred_auth_methods := AuthMethods + } } = Cfg, Data ) -> @@ -129,7 +133,8 @@ retrieve_token( Secret, Data#{ redirect_uri => make_callback_url(Cfg), - client_jwks => ClientJwks + client_jwks => ClientJwks, + preferred_auth_methods => AuthMethods } ) of @@ -165,6 +170,7 @@ retrieve_userinfo(Token, #{ Error end. +-dialyzer({nowarn_function, ensure_user_exists/1}). ensure_user_exists(<<>>) -> {error, <<"Username can not be empty">>}; ensure_user_exists(<<"undefined">>) -> diff --git a/rel/i18n/emqx_dashboard_sso_oidc.hocon b/rel/i18n/emqx_dashboard_sso_oidc.hocon index a9a2f61c4..a13abeab2 100644 --- a/rel/i18n/emqx_dashboard_sso_oidc.hocon +++ b/rel/i18n/emqx_dashboard_sso_oidc.hocon @@ -33,4 +33,13 @@ client_file_jwks_type.desc: client_file_jwks_file.desc: """The content of the JWKS.""" +preferred_auth_methods.desc: +"""Set the valid authentication methods and their priority.""" + +provider.desc: +"""The OIDC provider.""" + +fallback_methods.desc: +"""Some providers do not provide all the method items in the provider configuration, set this value as a fallback for those items.""" + } From abc255bb021adb51077398c31504e697977c7b82 Mon Sep 17 00:00:00 2001 From: firest Date: Mon, 24 Jun 2024 10:43:34 +0800 Subject: [PATCH 06/10] fix(oidc): make CI happy --- apps/emqx_dashboard_sso/rebar.config | 2 +- apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src | 2 +- apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl | 2 ++ mix.exs | 4 ++-- rel/i18n/emqx_dashboard_sso_oidc.hocon | 5 ++++- scripts/spellcheck/dicts/emqx.txt | 1 + 6 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/emqx_dashboard_sso/rebar.config b/apps/emqx_dashboard_sso/rebar.config index e9a52c56d..ae4125add 100644 --- a/apps/emqx_dashboard_sso/rebar.config +++ b/apps/emqx_dashboard_sso/rebar.config @@ -5,5 +5,5 @@ {emqx_ldap, {path, "../../apps/emqx_ldap"}}, {emqx_dashboard, {path, "../../apps/emqx_dashboard"}}, {esaml, {git, "https://github.com/emqx/esaml", {tag, "v1.1.3"}}}, - {oidcc, {git, "https://github.com/emqx/oidcc.git", {branch, "ev3.2.0"}}} + {oidcc, {git, "https://github.com/emqx/oidcc.git", {tag, "v3.2.0-1"}}} ]}. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src index ab40e3848..95d49a150 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src @@ -1,6 +1,6 @@ {application, emqx_dashboard_sso, [ {description, "EMQX Dashboard Single Sign-On"}, - {vsn, "0.1.4"}, + {vsn, "0.1.5"}, {registered, [emqx_dashboard_sso_sup]}, {applications, [ kernel, diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl index f904230c7..1a4562336 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl @@ -150,6 +150,8 @@ fields(login) -> desc(oidc) -> "OIDC"; +desc(client_file_jwks) -> + ?DESC(client_file_jwks); desc(_) -> undefined. diff --git a/mix.exs b/mix.exs index 2cc48d979..a94707da2 100644 --- a/mix.exs +++ b/mix.exs @@ -68,14 +68,14 @@ defmodule EMQXUmbrella.MixProject do {:rulesql, github: "emqx/rulesql", tag: "0.2.1"}, {:observer_cli, "1.7.1"}, {:system_monitor, github: "ieQu1/system_monitor", tag: "3.0.5"}, - {:telemetry, "1.1.0"}, + {:telemetry, "1.1.0", override: true}, # in conflict by emqtt and hocon {:getopt, "1.0.2", override: true}, {:snabbkaffe, github: "kafka4beam/snabbkaffe", tag: "1.0.10", override: true}, {:hocon, github: "emqx/hocon", tag: "0.42.2", override: true}, {:emqx_http_lib, github: "emqx/emqx_http_lib", tag: "0.5.3", override: true}, {:esasl, github: "emqx/esasl", tag: "0.2.1"}, - {:jose, github: "potatosalad/erlang-jose", tag: "1.11.2"}, + {:jose, github: "potatosalad/erlang-jose", tag: "1.11.2", override: true}, # in conflict by ehttpc and emqtt {:gun, github: "emqx/gun", tag: "1.3.11", override: true}, # in conflict by emqx_connector and system_monitor diff --git a/rel/i18n/emqx_dashboard_sso_oidc.hocon b/rel/i18n/emqx_dashboard_sso_oidc.hocon index a13abeab2..7dd6cf497 100644 --- a/rel/i18n/emqx_dashboard_sso_oidc.hocon +++ b/rel/i18n/emqx_dashboard_sso_oidc.hocon @@ -13,7 +13,7 @@ scopes.desc: """The scopes, its default value is `["openid"]`.""" name_var.desc: -"""A template to map OIDC user information to a DashBoard name, its default value is `${sub}`.""" +"""A template to map OIDC user information to a Dashboard name, its default value is `${sub}`.""" dashboard_addr.desc: """The address of the EMQX Dashboard.""" @@ -30,6 +30,9 @@ client_jwks.desc: client_file_jwks_type.desc: """The JWKS source type.""" +client_file_jwks.desc: +"""Set JWKS from file.""" + client_file_jwks_file.desc: """The content of the JWKS.""" diff --git a/scripts/spellcheck/dicts/emqx.txt b/scripts/spellcheck/dicts/emqx.txt index ce08d0f6b..347020b63 100644 --- a/scripts/spellcheck/dicts/emqx.txt +++ b/scripts/spellcheck/dicts/emqx.txt @@ -311,3 +311,4 @@ doc_as_upsert upsert aliyun OID +PKCE From 983f02ea1bbbbe9fc85b9971e054d2a1e6391342 Mon Sep 17 00:00:00 2001 From: JimMoen Date: Tue, 25 Jun 2024 20:37:54 +0800 Subject: [PATCH 07/10] refactor: separate CONNECT flags validation funcs --- apps/emqx/src/emqx_frame.erl | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/apps/emqx/src/emqx_frame.erl b/apps/emqx/src/emqx_frame.erl index 4b4a2d5cf..873311ed4 100644 --- a/apps/emqx/src/emqx_frame.erl +++ b/apps/emqx/src/emqx_frame.erl @@ -284,28 +284,18 @@ parse_connect(FrameBin, StrictMode) -> end, parse_connect2(ProtoName, Rest, StrictMode). -% Note: return malformed if reserved flag is not 0. parse_connect2( ProtoName, <>, StrictMode ) -> - case Reserved of - 0 -> ok; - 1 -> ?PARSE_ERR(reserved_connect_flag) - end, - WillFlag = bool(WillFlagB), - WillRetain = bool(WillRetainB), - case WillFlag of - %% MQTT-v3.1.1-[MQTT-3.1.2-13], MQTT-v5.0-[MQTT-3.1.2-11] - false when WillQoS > 0 -> ?PARSE_ERR(invalid_will_qos); - %% MQTT-v3.1.1-[MQTT-3.1.2-14], MQTT-v5.0-[MQTT-3.1.2-12] - true when WillQoS > 2 -> ?PARSE_ERR(invalid_will_qos); - %% MQTT-v3.1.1-[MQTT-3.1.2-15], MQTT-v5.0-[MQTT-3.1.2-13] - false when WillRetain -> ?PARSE_ERR(invalid_will_retain); - _ -> ok - end, + _ = validate_connect_reserved(Reserved), + _ = validate_connect_will( + WillFlag = bool(WillFlagB), + WillRetain = bool(WillRetainB), + WillQoS + ), {Properties, Rest3} = parse_properties(Rest2, ProtoVer, StrictMode), {ClientId, Rest4} = parse_utf8_string_with_cause(Rest3, StrictMode, invalid_clientid), ConnPacket = #mqtt_packet_connect{ @@ -1133,6 +1123,18 @@ validate_subqos([3 | _]) -> ?PARSE_ERR(bad_subqos); validate_subqos([_ | T]) -> validate_subqos(T); validate_subqos([]) -> ok. +%% MQTT-v3.1.1-[MQTT-3.1.2-3], MQTT-v5.0-[MQTT-3.1.2-3] +validate_connect_reserved(0) -> ok; +validate_connect_reserved(1) -> ?PARSE_ERR(reserved_connect_flag). + +%% MQTT-v3.1.1-[MQTT-3.1.2-13], MQTT-v5.0-[MQTT-3.1.2-11] +validate_connect_will(false, _, WillQos) when WillQos > 0 -> ?PARSE_ERR(invalid_will_qos); +%% MQTT-v3.1.1-[MQTT-3.1.2-14], MQTT-v5.0-[MQTT-3.1.2-12] +validate_connect_will(true, _, WillQoS) when WillQoS > 2 -> ?PARSE_ERR(invalid_will_qos); +%% MQTT-v3.1.1-[MQTT-3.1.2-15], MQTT-v5.0-[MQTT-3.1.2-13] +validate_connect_will(false, WillRetain, _) when WillRetain -> ?PARSE_ERR(invalid_will_retain); +validate_connect_will(_, _, _) -> ok. + bool(0) -> false; bool(1) -> true. From 02a9885aa5bac909a4253138422276f8be6790e1 Mon Sep 17 00:00:00 2001 From: JimMoen Date: Tue, 25 Jun 2024 23:13:15 +0800 Subject: [PATCH 08/10] fix(mqtt): check password flag to respect protocol spec --- apps/emqx/src/emqx_frame.erl | 26 +++++++++++++++++++++++--- changes/ce/fix-13334.en.md | 4 ++++ 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 changes/ce/fix-13334.en.md diff --git a/apps/emqx/src/emqx_frame.erl b/apps/emqx/src/emqx_frame.erl index 873311ed4..a1c9084dd 100644 --- a/apps/emqx/src/emqx_frame.erl +++ b/apps/emqx/src/emqx_frame.erl @@ -286,7 +286,7 @@ parse_connect(FrameBin, StrictMode) -> parse_connect2( ProtoName, - <>, StrictMode ) -> @@ -296,6 +296,12 @@ parse_connect2( WillRetain = bool(WillRetainB), WillQoS ), + _ = validate_connect_password_flag( + StrictMode, + ProtoVer, + UsernameFlag = bool(UsernameFlagB), + PasswordFlag = bool(PasswordFlagB) + ), {Properties, Rest3} = parse_properties(Rest2, ProtoVer, StrictMode), {ClientId, Rest4} = parse_utf8_string_with_cause(Rest3, StrictMode, invalid_clientid), ConnPacket = #mqtt_packet_connect{ @@ -318,14 +324,14 @@ parse_connect2( fun(Bin) -> parse_utf8_string_with_cause(Bin, StrictMode, invalid_username) end, - bool(UsernameFlag) + UsernameFlag ), {Password, Rest7} = parse_optional( Rest6, fun(Bin) -> parse_utf8_string_with_cause(Bin, StrictMode, invalid_password) end, - bool(PasswordFlag) + PasswordFlag ), case Rest7 of <<>> -> @@ -1135,6 +1141,20 @@ validate_connect_will(true, _, WillQoS) when WillQoS > 2 -> ?PARSE_ERR(invalid_w validate_connect_will(false, WillRetain, _) when WillRetain -> ?PARSE_ERR(invalid_will_retain); validate_connect_will(_, _, _) -> ok. +%% MQTT-v3.1 +%% Username flag and password flag are not strongly related +%% https://public.dhe.ibm.com/software/dw/webservices/ws-mqtt/mqtt-v3r1.html#connect +validate_connect_password_flag(true, ?MQTT_PROTO_V3, _, _) -> + ok; +%% MQTT-v3.1.1-[MQTT-3.1.2-22] +validate_connect_password_flag(true, ?MQTT_PROTO_V4, UsernameFlag, PasswordFlag) -> + %% BUG-FOR-BUG compatible, only check when `strict-mode` + UsernameFlag orelse PasswordFlag andalso ?PARSE_ERR(invalid_password_flag); +validate_connect_password_flag(true, ?MQTT_PROTO_V5, _, _) -> + ok; +validate_connect_password_flag(_, _, _, _) -> + ok. + bool(0) -> false; bool(1) -> true. diff --git a/changes/ce/fix-13334.en.md b/changes/ce/fix-13334.en.md new file mode 100644 index 000000000..e638be9ce --- /dev/null +++ b/changes/ce/fix-13334.en.md @@ -0,0 +1,4 @@ +Check the `PasswordFlag` of the MQTT v3.1.1 CONNECT packet in strict mode to comply with the protocol. + +> [!NOTE] +> To ensure BUG-TO-BUG compatibility, this check is performed only in strict mode. From ed130fdc57a21af8ee687eed55571418b82d2f3f Mon Sep 17 00:00:00 2001 From: JimMoen Date: Tue, 25 Jun 2024 23:14:23 +0800 Subject: [PATCH 09/10] test: MQTT CONNECT flags check --- apps/emqx/test/emqx_frame_SUITE.erl | 31 ++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/emqx/test/emqx_frame_SUITE.erl b/apps/emqx/test/emqx_frame_SUITE.erl index 2457c3faf..9c8a99547 100644 --- a/apps/emqx/test/emqx_frame_SUITE.erl +++ b/apps/emqx/test/emqx_frame_SUITE.erl @@ -706,9 +706,15 @@ t_invalid_clientid(_) -> ). %% for regression: `password` must be `undefined` +%% BUG-FOR-BUG compatible t_undefined_password(_) -> - Payload = <<16, 19, 0, 4, 77, 81, 84, 84, 4, 130, 0, 60, 0, 2, 97, 49, 0, 3, 97, 97, 97>>, - {ok, Packet, <<>>, {none, _}} = emqx_frame:parse(Payload), + %% Username Flag = true + %% Password Flag = false + %% Clean Session = true + ConnectFlags = <<2#1000:4, 2#0010:4>>, + ConnBin = + <<16, 17, 0, 4, 77, 81, 84, 84, 4, ConnectFlags/binary, 0, 60, 0, 2, 97, 49, 0, 1, 97>>, + {ok, Packet, <<>>, {none, _}} = emqx_frame:parse(ConnBin), Password = undefined, ?assertEqual( #mqtt_packet{ @@ -732,7 +738,7 @@ t_undefined_password(_) -> will_props = #{}, will_topic = undefined, will_payload = undefined, - username = <<"aaa">>, + username = <<"a">>, password = Password }, payload = undefined @@ -741,6 +747,25 @@ t_undefined_password(_) -> ), ok. +t_invalid_password_flag(_) -> + %% Username Flag = false + %% Password Flag = true + %% Clean Session = true + ConnectFlags = <<2#0100:4, 2#0010:4>>, + ConnectBin = + <<16, 17, 0, 4, 77, 81, 84, 84, 4, ConnectFlags/binary, 0, 60, 0, 2, 97, 49, 0, 1, 97>>, + ?assertMatch( + {ok, _, _, _}, + emqx_frame:parse(ConnectBin) + ), + + StrictModeParseState = emqx_frame:initial_parse_state(#{strict_mode => true}), + ?assertException( + throw, + {frame_parse_error, invalid_password_flag}, + emqx_frame:parse(ConnectBin, StrictModeParseState) + ). + t_invalid_will_retain(_) -> ConnectFlags = <<2#01100000>>, ConnectBin = From 3d398873f195bd29105d078aed3c778eeb7e4ad4 Mon Sep 17 00:00:00 2001 From: firest Date: Wed, 26 Jun 2024 15:49:13 +0800 Subject: [PATCH 10/10] fix(oidc): return to dashboard when provider calls back fixed a bug when updating config --- .../src/emqx_dashboard_sso_api.erl | 9 ++-- .../src/emqx_dashboard_sso_manager.erl | 9 +++- .../src/emqx_dashboard_sso_oidc.erl | 9 ++-- .../src/emqx_dashboard_sso_oidc_api.erl | 51 ++++++++++++------- .../src/emqx_dashboard_sso_oidc_session.erl | 3 +- .../src/emqx_dashboard_sso_saml.erl | 2 +- 6 files changed, 54 insertions(+), 29 deletions(-) diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl index 20bc99de1..81abc8f19 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl @@ -33,7 +33,7 @@ backend/2 ]). --export([sso_parameters/1, login_meta/3]). +-export([sso_parameters/1, login_meta/4]). -define(REDIRECT, 'REDIRECT'). -define(BAD_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD'). @@ -168,7 +168,7 @@ login(post, #{bindings := #{backend := Backend}, body := Body} = Request) -> request => emqx_utils:redact(Request) }), Username = maps:get(<<"username">>, Body), - {200, login_meta(Username, Role, Token)}; + {200, login_meta(Username, Role, Token, Backend)}; {redirect, Redirect} -> ?SLOG(info, #{ msg => "dashboard_sso_login_redirect", @@ -286,11 +286,12 @@ to_redacted_json(Data) -> end ). -login_meta(Username, Role, Token) -> +login_meta(Username, Role, Token, Backend) -> #{ username => Username, role => Role, token => Token, version => iolist_to_binary(proplists:get_value(version, emqx_sys:info())), - license => #{edition => emqx_release:edition()} + license => #{edition => emqx_release:edition()}, + backend => Backend }. diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl index c1d450ef2..6834da9e9 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_manager.erl @@ -107,7 +107,14 @@ get_backend_status(Backend, _) -> end. update(Backend, Config) -> - update_config(Backend, {?FUNCTION_NAME, Backend, Config}). + UpdateConf = + case emqx:get_raw_config(?MOD_KEY_PATH(Backend), #{}) of + RawConf when is_map(RawConf) -> + emqx_utils:deobfuscate(Config, RawConf); + null -> + Config + end, + update_config(Backend, {?FUNCTION_NAME, Backend, UpdateConf}). delete(Backend) -> update_config(Backend, {?FUNCTION_NAME, Backend}). diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl index 1a4562336..dbc0d7f0b 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl @@ -62,9 +62,8 @@ fields(oidc) -> #{desc => ?DESC(clientid), required => true} )}, {secret, - ?HOCON( - binary(), - #{desc => ?DESC(secret), required => true} + emqx_schema_secret:mk( + maps:merge(#{desc => ?DESC(secret), required => true}, #{}) )}, {scopes, ?HOCON( @@ -82,7 +81,7 @@ fields(oidc) -> default => <<"http://127.0.0.1:18083">> })}, {session_expiry, - ?HOCON(emqx_schema:timeout_duration_ms(), #{ + ?HOCON(emqx_schema:timeout_duration_s(), #{ desc => ?DESC(session_expiry), default => <<"30s">> })}, @@ -217,7 +216,7 @@ login( oidcc:create_redirect_url( ?PROVIDER_SVR_NAME, ClientId, - Secret, + emqx_secret:unwrap(Secret), Opts#{ state => State, client_jwks => ClientJwks, diff --git a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl index 3b5c9f5d8..3514b4fbb 100644 --- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl +++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl @@ -30,6 +30,14 @@ -define(BAD_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD'). -define(BACKEND_NOT_FOUND, 'BACKEND_NOT_FOUND'). + +-define(RESPHEADERS, #{ + <<"cache-control">> => <<"no-cache">>, + <<"pragma">> => <<"no-cache">>, + <<"content-type">> => <<"text/plain">> +}). +-define(REDIRECT_BODY, <<"Redirecting...">>). + -define(TAGS, <<"Dashboard Single Sign-On">>). -define(BACKEND, oidc). -define(BASE_PATH, "/api/v5"). @@ -66,11 +74,12 @@ schema("/sso/oidc/callback") -> %%-------------------------------------------------------------------- code_callback(get, #{query_string := QS}) -> case ensure_sso_state(QS) of - {ok, Username, Role, DashboardToken} -> + {ok, Target} -> ?SLOG(info, #{ msg => "dashboard_sso_login_successful" }), - {200, login_meta(Username, Role, DashboardToken)}; + + {302, ?RESPHEADERS#{<<"location">> => Target}, ?REDIRECT_BODY}; {error, invalid_backend} -> {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}}; {error, Reason} -> @@ -130,7 +139,7 @@ retrieve_token( Code, Name, ClientId, - Secret, + emqx_secret:unwrap(Secret), Data#{ redirect_uri => make_callback_url(Cfg), client_jwks => ClientJwks, @@ -144,18 +153,21 @@ retrieve_token( Error end. -retrieve_userinfo(Token, #{ - name := Name, - client_jwks := ClientJwks, - config := #{clientid := ClientId, secret := Secret}, - name_tokens := NameTks -}) -> +retrieve_userinfo( + Token, + #{ + name := Name, + client_jwks := ClientJwks, + config := #{clientid := ClientId, secret := Secret}, + name_tokens := NameTks + } = Cfg +) -> case oidcc:retrieve_userinfo( Token, Name, ClientId, - Secret, + emqx_secret:unwrap(Secret), #{client_jwks => ClientJwks} ) of @@ -165,29 +177,29 @@ retrieve_userinfo(Token, #{ user_info => UserInfo }), Username = emqx_placeholder:proc_tmpl(NameTks, UserInfo), - ensure_user_exists(Username); + ensure_user_exists(Cfg, Username); {error, _Reason} = Error -> Error end. --dialyzer({nowarn_function, ensure_user_exists/1}). -ensure_user_exists(<<>>) -> +-dialyzer({nowarn_function, ensure_user_exists/2}). +ensure_user_exists(_Cfg, <<>>) -> {error, <<"Username can not be empty">>}; -ensure_user_exists(<<"undefined">>) -> +ensure_user_exists(_Cfg, <<"undefined">>) -> {error, <<"Username can not be undefined">>}; -ensure_user_exists(Username) -> +ensure_user_exists(Cfg, Username) -> case emqx_dashboard_admin:lookup_user(?BACKEND, Username) of [User] -> case emqx_dashboard_token:sign(User, <<>>) of {ok, Role, Token} -> - {ok, Username, Role, Token}; + {ok, login_redirect_target(Cfg, Username, Role, Token)}; Error -> Error end; [] -> case emqx_dashboard_admin:add_sso_user(?BACKEND, Username, ?ROLE_VIEWER, <<>>) of {ok, _} -> - ensure_user_exists(Username); + ensure_user_exists(Cfg, Username); Error -> Error end @@ -195,3 +207,8 @@ ensure_user_exists(Username) -> make_callback_url(#{config := #{dashboard_addr := Addr}}) -> list_to_binary(binary_to_list(Addr) ++ ?BASE_PATH ++ ?CALLBACK_PATH). + +login_redirect_target(#{config := #{dashboard_addr := Addr}}, Username, Role, Token) -> + LoginMeta = emqx_dashboard_sso_api:login_meta(Username, Role, Token, oidc), + MetaBin = base64:encode(emqx_utils_json:encode(LoginMeta)), + <>) =:= nomatch). login_redirect_target(DashboardAddr, Username, Role, Token) -> - LoginMeta = emqx_dashboard_sso_api:login_meta(Username, Role, Token), + LoginMeta = emqx_dashboard_sso_api:login_meta(Username, Role, Token, saml), <