diff --git a/apps/emqx_dashboard_sso/rebar.config b/apps/emqx_dashboard_sso/rebar.config
index 90172af3d..ae4125add 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/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 7f5e8f04a..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,13 +1,14 @@
{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,
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_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 20289c2b3..6834da9e9 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
@@ -106,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}).
@@ -154,8 +162,7 @@ init([]) ->
{read_concurrency, true}
]
),
- start_backend_services(),
- {ok, #{}}.
+ {ok, #{}, {continue, start_backend_services}}.
handle_call(_Request, _From, State) ->
Reply = ok,
@@ -167,6 +174,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
new file mode 100644
index 000000000..dbc0d7f0b
--- /dev/null
+++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc.erl
@@ -0,0 +1,294 @@
+%%--------------------------------------------------------------------
+%% 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, ?MODULE).
+-define(RESPHEADERS, #{
+ <<"cache-control">> => <<"no-cache">>,
+ <<"pragma">> => <<"no-cache">>,
+ <<"content-type">> => <<"text/plain">>
+}).
+-define(REDIRECT_BODY, <<"Redirecting...">>).
+-define(PKCE_VERIFIER_LEN, 60).
+
+%%------------------------------------------------------------------------------
+%% 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,
+ emqx_schema_secret:mk(
+ maps:merge(#{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">>
+ })},
+ {session_expiry,
+ ?HOCON(emqx_schema:timeout_duration_s(), #{
+ desc => ?DESC(session_expiry),
+ default => <<"30s">>
+ })},
+ {require_pkce,
+ ?HOCON(boolean(), #{
+ 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)]), #{
+ 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])
+ ].
+
+desc(oidc) ->
+ "OIDC";
+desc(client_file_jwks) ->
+ ?DESC(client_file_jwks);
+desc(_) ->
+ undefined.
+
+%%------------------------------------------------------------------------------
+%% APIs
+%%------------------------------------------------------------------------------
+
+create(#{name_var := NameVar} = Config) ->
+ case
+ emqx_dashboard_sso_oidc_session:start(
+ ?PROVIDER_SVR_NAME,
+ Config
+ )
+ of
+ {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
+ ClientJwks = init_client_jwks(Config),
+ {ok, #{
+ name => ?PROVIDER_SVR_NAME,
+ config => Config,
+ client_jwks => ClientJwks,
+ name_tokens => emqx_placeholder:preproc_tmpl(NameVar)
+ }}
+ end.
+
+update(Config, State) ->
+ destroy(State),
+ create(Config).
+
+destroy(State) ->
+ emqx_dashboard_sso_oidc_session:stop(),
+ try_delete_jwks_file(State).
+
+-dialyzer({nowarn_function, login/2}).
+login(
+ _Req,
+ #{
+ client_jwks := ClientJwks,
+ config := #{
+ clientid := ClientId,
+ secret := Secret,
+ scopes := Scopes,
+ require_pkce := RequirePKCE,
+ preferred_auth_methods := AuthMethods
+ }
+ } = Cfg
+) ->
+ Nonce = emqx_dashboard_sso_oidc_session:random_bin(),
+ 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,
+ emqx_secret:unwrap(Secret),
+ Opts#{
+ state => State,
+ client_jwks => ClientJwks,
+ preferred_auth_methods => AuthMethods
+ }
+ )
+ of
+ {ok, [Base, Delimiter, Params]} ->
+ RedirectUri = <>,
+ Redirect = {302, ?RESPHEADERS#{<<"location">> => RedirectUri}, ?REDIRECT_BODY},
+ {redirect, Redirect};
+ {error, _Reason} = Error ->
+ 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) ->
+ 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
new file mode 100644
index 000000000..3514b4fbb
--- /dev/null
+++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_api.erl
@@ -0,0 +1,214 @@
+%%--------------------------------------------------------------------
+%% 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(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").
+-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 := QS}) ->
+ case ensure_sso_state(QS) of
+ {ok, Target} ->
+ ?SLOG(info, #{
+ msg => "dashboard_sso_login_successful"
+ }),
+
+ {302, ?RESPHEADERS#{<<"location">> => Target}, ?REDIRECT_BODY};
+ {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
+%%--------------------------------------------------------------------
+
+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_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,
+ client_jwks := ClientJwks,
+ config := #{
+ clientid := ClientId,
+ secret := Secret,
+ preferred_auth_methods := AuthMethods
+ }
+ } = Cfg,
+ Data
+) ->
+ case
+ oidcc:retrieve_token(
+ Code,
+ Name,
+ ClientId,
+ emqx_secret:unwrap(Secret),
+ Data#{
+ redirect_uri => make_callback_url(Cfg),
+ client_jwks => ClientJwks,
+ preferred_auth_methods => AuthMethods
+ }
+ )
+ of
+ {ok, Token} ->
+ retrieve_userinfo(Token, Cfg);
+ {error, _Reason} = Error ->
+ Error
+ end.
+
+retrieve_userinfo(
+ Token,
+ #{
+ name := Name,
+ client_jwks := ClientJwks,
+ config := #{clientid := ClientId, secret := Secret},
+ name_tokens := NameTks
+ } = Cfg
+) ->
+ case
+ oidcc:retrieve_userinfo(
+ Token,
+ Name,
+ ClientId,
+ emqx_secret:unwrap(Secret),
+ #{client_jwks => ClientJwks}
+ )
+ of
+ {ok, UserInfo} ->
+ ?SLOG(debug, #{
+ msg => "sso_oidc_login_user_info",
+ user_info => UserInfo
+ }),
+ Username = emqx_placeholder:proc_tmpl(NameTks, UserInfo),
+ ensure_user_exists(Cfg, Username);
+ {error, _Reason} = Error ->
+ Error
+ end.
+
+-dialyzer({nowarn_function, ensure_user_exists/2}).
+ensure_user_exists(_Cfg, <<>>) ->
+ {error, <<"Username can not be empty">>};
+ensure_user_exists(_Cfg, <<"undefined">>) ->
+ {error, <<"Username can not be undefined">>};
+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, 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(Cfg, Username);
+ Error ->
+ Error
+ end
+ end.
+
+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)),
+ <>.
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..b28bcc64d
--- /dev/null
+++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_oidc_session.erl
@@ -0,0 +1,157 @@
+%%--------------------------------------------------------------------
+%% 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 := SessionExpiry0}) ->
+ case
+ emqx_dashboard_sso_sup:start_child(
+ oidcc_provider_configuration_worker,
+ [
+ #{
+ issuer => Issuer,
+ name => {local, Name}
+ }
+ ]
+ )
+ of
+ {error, _} = Error ->
+ Error;
+ _ ->
+ SessionExpiry = timer:seconds(SessionExpiry0),
+ 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_saml.erl b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl
index eaa550d64..9efd2effd 100644
--- a/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl
+++ b/apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl
@@ -273,7 +273,7 @@ is_msie(Headers) ->
not (binary:match(UA, <<"MSIE">>) =:= 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),
<>.
base64_login_meta(LoginMeta) ->
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..f82d8f749 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,26 @@
-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, 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, transient)).
+
+stop_child(Mod) ->
+ _ = supervisor:terminate_child(?MODULE, Mod),
+ _ = supervisor:delete_child(?MODULE, Mod),
+ ok.
+
init([]) ->
{ok,
{{one_for_one, 5, 100}, [
- ?CHILD(emqx_dashboard_sso_manager, 5000)
+ ?CHILD(emqx_dashboard_sso_manager)
]}}.
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
new file mode 100644
index 000000000..7dd6cf497
--- /dev/null
+++ b/rel/i18n/emqx_dashboard_sso_oidc.hocon
@@ -0,0 +1,48 @@
+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."""
+
+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."""
+
+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.desc:
+"""Set JWKS from file."""
+
+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."""
+
+}
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.."""
+
+}
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