Просмотр исходного кода

Merge pull request #13349 from zmstone/0627-release-572-to-release-57

0627 release 572 to release 57
zmstone 1 год назад
Родитель
Сommit
7e089dce6b

+ 41 - 19
apps/emqx/src/emqx_frame.erl

@@ -284,28 +284,24 @@ parse_connect(FrameBin, StrictMode) ->
     end,
     parse_connect2(ProtoName, Rest, StrictMode).
 
-% Note: return malformed if reserved flag is not 0.
 parse_connect2(
     ProtoName,
-    <<BridgeTag:4, ProtoVer:4, UsernameFlag:1, PasswordFlag:1, WillRetainB:1, WillQoS:2,
+    <<BridgeTag:4, ProtoVer:4, UsernameFlagB:1, PasswordFlagB:1, WillRetainB:1, WillQoS:2,
         WillFlagB:1, CleanStart:1, Reserved:1, KeepAlive:16/big, Rest2/binary>>,
     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
+    ),
+    _ = 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{
@@ -328,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
         <<>> ->
@@ -1133,6 +1129,32 @@ 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.
+
+%% 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.
 

+ 28 - 3
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 =

+ 2 - 1
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"}}}
 ]}.

+ 3 - 2
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, []},

+ 2 - 1
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) ->

+ 5 - 4
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
     }.

+ 16 - 3
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.

+ 294 - 0
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 = <<Base/binary, Delimiter/binary, Params/binary>>,
+            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.

+ 214 - 0
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)),
+    <<Addr/binary, "/?login_meta=", MetaBin/binary>>.

+ 157 - 0
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).

+ 1 - 1
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),
     <<DashboardAddr/binary, "/?login_meta=", (base64_login_meta(LoginMeta))/binary>>.
 
 base64_login_meta(LoginMeta) ->

+ 12 - 3
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)
         ]}}.

+ 4 - 0
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.

+ 2 - 2
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

+ 48 - 0
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."""
+
+}

+ 6 - 0
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.."""
+
+}

+ 1 - 0
scripts/spellcheck/dicts/emqx.txt

@@ -311,3 +311,4 @@ doc_as_upsert
 upsert
 aliyun
 OID
+PKCE