diff --git a/deps/rabbitmq_auth_backend_oauth2/src/rabbit_oauth2_mgmt_extension.erl b/deps/rabbitmq_auth_backend_oauth2/src/rabbit_oauth2_mgmt_extension.erl new file mode 100644 index 000000000000..05c9de49bfbe --- /dev/null +++ b/deps/rabbitmq_auth_backend_oauth2/src/rabbit_oauth2_mgmt_extension.erl @@ -0,0 +1,18 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. +%% + +-module(rabbit_oauth2_mgmt_extension). + +-behaviour(rabbit_mgmt_extension). + +-export([dispatcher/0, web_ui/0]). + +dispatcher() -> + [{"/oauth2/validate/token/decode", rabbit_oauth2_wm_validate_token, []}]. + +web_ui() -> + []. diff --git a/deps/rabbitmq_auth_backend_oauth2/src/rabbit_oauth2_wm_validate_token.erl b/deps/rabbitmq_auth_backend_oauth2/src/rabbit_oauth2_wm_validate_token.erl new file mode 100644 index 000000000000..201db4bc9d40 --- /dev/null +++ b/deps/rabbitmq_auth_backend_oauth2/src/rabbit_oauth2_wm_validate_token.erl @@ -0,0 +1,344 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. +%% + +-module(rabbit_oauth2_wm_validate_token). + +-export([init/2]). +-export([content_types_accepted/2, content_types_provided/2, is_authorized/2]). +-export([allowed_methods/2, accept_content/2, to_json/2]). +-export([variances/2]). +-export([validate_jwt_token_on_current_node/2]). % Export for RPC calls + +-include_lib("rabbitmq_management_agent/include/rabbit_mgmt_records.hrl"). +-include_lib("rabbit_common/include/rabbit.hrl"). +-include_lib("rabbitmq_auth_backend_oauth2/include/oauth2.hrl"). + +-import(uaa_jwt, [decode_and_verify/3]). + +%%-------------------------------------------------------------------- + +init(Req, _State) -> + {cowboy_rest, rabbit_mgmt_headers:set_common_permission_headers(Req, ?MODULE), #context{}}. + +variances(Req, Context) -> + {[<<"accept-encoding">>, <<"origin">>], Req, Context}. + +content_types_provided(ReqData, Context) -> + {rabbit_mgmt_util:responder_map(to_json), ReqData, Context}. + +content_types_accepted(ReqData, Context) -> + {[{'*', accept_content}], ReqData, Context}. + +allowed_methods(ReqData, Context) -> + {[<<"POST">>], ReqData, Context}. + +is_authorized(ReqData, Context) -> + rabbit_mgmt_util:is_authorized_admin(ReqData, Context). + +to_json(ReqData, Context) -> + rabbit_mgmt_util:reply(#{status => <<"ok">>}, ReqData, Context). + +accept_content(ReqData0, Context) -> + {ok, Body, ReqData} = rabbit_mgmt_util:read_complete_body(ReqData0), + rabbit_log:info("OAuth2 token validation request body: ~p", [Body]), + case rabbit_json:try_decode(Body) of + {ok, Payload} -> + rabbit_log:info("JSON decoded successfully: ~p", [Payload]), + case validate_token_payload(Payload) of + {ok, Result} -> + ResultProplist = maps:to_list(Result), + {true, cowboy_req:set_resp_body(rabbit_json:encode(ResultProplist), ReqData), Context}; + {error, Error} -> + rabbit_log:error("Token validation error: ~p", [Error]), + ErrorProplist = maps:to_list(Error), + rabbit_mgmt_util:bad_request(ErrorProplist, ReqData, Context) + end; + {error, JsonError} -> + rabbit_log:error("JSON decode error: ~p for body: ~p", [JsonError, Body]), + ErrorMap = #{ + message => <<"Invalid JSON in request body">> + }, + rabbit_mgmt_util:bad_request(maps:to_list(ErrorMap), ReqData, Context) + end. + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +validate_token_payload(Payload) -> + RequiredFields = [<<"jwks_uri">>, <<"resource_server_id">>, <<"jwt_token">>], + case check_required_fields(Payload, RequiredFields) of + {error, _} = Error -> + Error; + ok -> + JwksUri = maps:get(<<"jwks_uri">>, Payload), + ResourceServerId = maps:get(<<"resource_server_id">>, Payload), + Token = maps:get(<<"jwt_token">>, Payload), + HttpsOptions = maps:get(<<"https">>, Payload, #{}), + + put(validation_node, undefined), + put(validation_peer, undefined), + + try + % Start a new Erlang node for validation + NodeName = list_to_atom("oauth2_validate_" ++ integer_to_list(erlang:unique_integer([positive]))), + + {ok, Node} = start_validation_node(NodeName), + rabbit_log:debug("Started validation node: ~p", [Node]), + + % Run the validation on the new node + Result = rpc:call(Node, ?MODULE, validate_jwt_token_on_current_node, + [Token, #{jwks_uri => JwksUri, + resource_server_id => ResourceServerId, + https_options => HttpsOptions}]), + + case Result of + {badrpc, Reason} -> + rabbit_log:error("JWT validation RPC error: ~p", [Reason]), + {error, #{ + message => list_to_binary(io_lib:format("JWT validation error: ~p", [Reason])) + }}; + ValidationResult -> + ValidationResult + end + catch + E:R:S -> + rabbit_log:error("Token validation error: ~p:~p:~p", [E, R, S]), + {error, #{ + message => list_to_binary(io_lib:format("~p", [R])), + stacktrace => list_to_binary(io_lib:format("~p", [S])) + }} + after + case get(validation_peer) of + undefined -> ok; + Peer -> + rabbit_log:debug("Stopping validation node: ~p", [get(validation_node)]), + % Stop the peer process + unlink(Peer), + exit(Peer, normal), + erase(validation_peer), + erase(validation_node) + end + end + end. + +check_required_fields(Payload, RequiredFields) -> + check_required_fields(Payload, RequiredFields, []). + +check_required_fields(_Payload, [], []) -> + ok; +check_required_fields(_Payload, [], MissingFields) -> + {error, #{ + message => <<"Missing required fields">>, + missing_fields => lists:reverse(MissingFields) + }}; +check_required_fields(Payload, [Field | Rest], MissingFields) -> + case maps:get(Field, Payload, undefined) of + undefined -> + check_required_fields(Payload, Rest, [Field | MissingFields]); + _ -> + check_required_fields(Payload, Rest, MissingFields) + end. + +start_validation_node(_NodeName) -> + rabbit_log:debug("Starting validation node with peer module"), + + CodePath = code:get_path(), + rabbit_log:debug("Code path length: ~p", [length(CodePath)]), + + NodeConfig = #{ + name => list_to_atom("oauth2_validate_" ++ integer_to_list(erlang:unique_integer([positive]))), + args => [] + }, + rabbit_log:debug("NodeConfig: ~p", [NodeConfig]), + + Result = (catch peer:start_link(NodeConfig)), + rabbit_log:debug("peer:start_link result: ~p", [Result]), + + case Result of + {ok, Peer, Node} -> + put(validation_peer, Peer), + put(validation_node, Node), + + % Set up the code path on the peer node + _ = rpc:call(Node, code, set_path, [CodePath]), + + % Start required applications + _ = rpc:call(Node, application, ensure_all_started, [inets]), + _ = rpc:call(Node, application, ensure_all_started, [ssl]), + _ = rpc:call(Node, application, ensure_all_started, [crypto]), + _ = rpc:call(Node, application, ensure_all_started, [asn1]), + _ = rpc:call(Node, application, ensure_all_started, [public_key]), + + % Copy essential environment variables from the main node + case application:get_env(rabbit, feature_flags_file) of + {ok, FeatureFlagsFile} -> + _ = rpc:call(Node, application, set_env, [rabbit, feature_flags_file, FeatureFlagsFile]); + undefined -> + ok + end, + + case application:get_env(rabbit, enabled_plugins_file) of + {ok, EnabledPluginsFile} -> + _ = rpc:call(Node, application, set_env, [rabbit, enabled_plugins_file, EnabledPluginsFile]); + undefined -> + ok + end, + + % Set up a minimal data directory for the peer node + case application:get_env(rabbit, data_dir) of + {ok, DataDir} -> + PeerDataDir = DataDir ++ "_validation_" ++ integer_to_list(erlang:unique_integer([positive])), + _ = rpc:call(Node, application, set_env, [rabbit, data_dir, PeerDataDir]); + undefined -> + % Create a temporary data directory + TempDataDir = "/tmp/rabbitmq_validation_" ++ integer_to_list(erlang:unique_integer([positive])), + _ = rpc:call(Node, application, set_env, [rabbit, data_dir, TempDataDir]) + end, + + % Set up other essential RabbitMQ environment variables + _ = rpc:call(Node, application, set_env, [rabbit, cluster_nodes, {[], disc}]), + _ = rpc:call(Node, application, set_env, [rabbit, default_user, <<"guest">>]), + _ = rpc:call(Node, application, set_env, [rabbit, default_pass, <<"guest">>]), + + % Load required modules + {module, _} = rpc:call(Node, code, ensure_loaded, [rabbit_oauth2_resource_server]), + {module, _} = rpc:call(Node, code, ensure_loaded, [uaa_jwt]), + {module, _} = rpc:call(Node, code, ensure_loaded, [oauth2_client]), + + {ok, Node}; + Error -> + rabbit_log:error("Failed to start peer node: ~p", [Error]), + Error + end. + +validate_jwt_token_on_current_node(Token, Config) -> + ValidationProviderId = <<"oauth2_validation_provider">>, + + try + JwksUri = maps:get(jwks_uri, Config), + ResourceServerId = maps:get(resource_server_id, Config), + HttpsOptions = maps:get(https_options, Config, #{}), + + SslOptions = extract_ssl_options_from_https(HttpsOptions), + + Algorithms = [<<"RS256">>], + + ResourceServer = rabbit_oauth2_resource_server:new_resource_server(ResourceServerId), + ValidationResourceServer = ResourceServer#resource_server{ + oauth_provider_id = ValidationProviderId + }, + + InternalOAuthProvider = #internal_oauth_provider{ + id = ValidationProviderId, + default_key = undefined, + algorithms = Algorithms + }, + + % Create a temporary provider for validation + CurrentProviders = case application:get_env(rabbitmq_auth_backend_oauth2, oauth_providers) of + {ok, Providers} -> Providers; + undefined -> #{} + end, + UpdatedProviders = maps:put(ValidationProviderId, + [{jwks_uri, JwksUri}, + {https, SslOptions}], + CurrentProviders), + + application:set_env(rabbitmq_auth_backend_oauth2, oauth_providers, UpdatedProviders), + + % Validate the token using uaa_jwt:decode_and_verify/3 + ValidationResult = case decode_and_verify(Token, ValidationResourceServer, InternalOAuthProvider) of + {true, Fields} -> + % Check if the token has expired + Now = os:system_time(seconds), + case maps:get(<<"exp">>, Fields, undefined) of + Exp when is_integer(Exp), Exp =< Now -> + Msg = rabbit_misc:format("Provided JWT token has expired at timestamp ~tp (validated at ~tp)", [Exp, Now]), + rabbit_log:error(Msg), + {ok, #{ + valid => false, + jwks_uri => JwksUri, + resource_server_id => ResourceServerId, + error => <<"token_expired">>, + error_message => list_to_binary(Msg) + }}; + _ -> + {ok, #{ + valid => true, + jwks_uri => JwksUri, + resource_server_id => ResourceServerId, + decoded_token => Fields + }} + end; + {false, _} -> + {error, #{ + message => <<"Invalid JWT token signature">> + }}; + {error, Reason} -> + ErrorStr1 = lists:flatten(io_lib:format("JWT validation error: ~p", [Reason])), + ErrorMessage1 = list_to_binary(re:replace(ErrorStr1, "[\n\s]+", " ", [global, {return, list}])), + {error, #{ + message => ErrorMessage1 + }} + end, + + case application:get_env(rabbitmq_auth_backend_oauth2, oauth_providers) of + {ok, CleanupProvidersMap} -> + CleanupCleanedProviders = maps:remove(ValidationProviderId, CleanupProvidersMap), + application:set_env(rabbitmq_auth_backend_oauth2, oauth_providers, CleanupCleanedProviders); + _ -> + ok + end, + ValidationResult + catch + E:R:S -> + case application:get_env(rabbitmq_auth_backend_oauth2, oauth_providers) of + {ok, ErrorProvidersMap} -> + ErrorCleanedProviders = maps:remove(ValidationProviderId, ErrorProvidersMap), + application:set_env(rabbitmq_auth_backend_oauth2, oauth_providers, ErrorCleanedProviders); + _ -> + ok + end, + rabbit_log:error("JWT validation error: ~p:~p:~p", [E, R, S]), + ErrorStr2 = lists:flatten(io_lib:format("JWT validation error: ~p", [R])), + ErrorMessage2 = list_to_binary(re:replace(ErrorStr2, "[\n\s]+", " ", [global, {return, list}])), + {error, #{ + message => ErrorMessage2 + }} + end. + +extract_ssl_options_from_https(HttpsOptions) when is_map(HttpsOptions) -> + rabbit_log:debug("HTTPS options: ~p", [HttpsOptions]), + + SslOptionsMap = maps:fold( + fun(Key, Value, Acc) -> + case Key of + <<"cacertfile">> -> + maps:put(cacertfile, binary_to_list(Value), Acc); + <<"certfile">> -> + maps:put(certfile, binary_to_list(Value), Acc); + <<"keyfile">> -> + maps:put(keyfile, binary_to_list(Value), Acc); + <<"verify">> -> + maps:put(verify, Value, Acc); + <<"fail_if_no_peer_cert">> -> + maps:put(fail_if_no_peer_cert, Value, Acc); + _ -> + Acc + end + end, + #{}, + HttpsOptions + ), + + rabbit_log:debug("SSL options map: ~p", [SslOptionsMap]), + SslOptions = oauth2_client:extract_ssl_options_as_list(SslOptionsMap), + rabbit_log:debug("Extracted SSL options: ~p", [SslOptions]), + SslOptions; +extract_ssl_options_from_https(_) -> + []. diff --git a/deps/rabbitmq_auth_backend_oauth2/test/oauth2_validate_endpoint_SUITE.erl b/deps/rabbitmq_auth_backend_oauth2/test/oauth2_validate_endpoint_SUITE.erl new file mode 100644 index 000000000000..a1bade176fb6 --- /dev/null +++ b/deps/rabbitmq_auth_backend_oauth2/test/oauth2_validate_endpoint_SUITE.erl @@ -0,0 +1,363 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. +%% + +-module(oauth2_validate_endpoint_SUITE). + +-compile(export_all). + +-include_lib("rabbit_common/include/rabbit.hrl"). +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include("oauth2.hrl"). + +-import(rabbit_oauth2_wm_validate_token, [ + validate_jwt_token_on_current_node/2 +]). + +all() -> + [ + test_validate_token_missing_required_fields, + test_validate_token_invalid_json, + test_validate_token_with_valid_payload, + test_validate_token_with_invalid_token, + test_validate_token_with_expired_token, + test_validate_token_with_invalid_jwks_uri, + test_validate_token_with_ssl_options, + test_validate_token_payload_validation, + test_extract_ssl_options_from_https, + {group, with_mock_jwks_server} + ]. + +groups() -> + [ + {with_mock_jwks_server, [], [ + test_validate_token_with_mock_server, + test_validate_token_with_ssl_verification + ]} + ]. + +init_per_suite(Config) -> + application:load(rabbitmq_auth_backend_oauth2), + rabbit_ct_helpers:run_setup_steps(Config, []). + +end_per_suite(Config) -> + rabbit_ct_helpers:run_teardown_steps(Config). + +init_per_group(with_mock_jwks_server, Config) -> + %% Start a mock JWKS server for testing + Config; +init_per_group(_, Config) -> + Config. + +end_per_group(_, Config) -> + Config. + +%% +%% Test Cases +%% + +test_validate_token_missing_required_fields(_) -> + %% Test missing jwks_uri + Payload1 = #{ + <<"resource_server_id">> => <<"rabbitmq">>, + <<"jwt_token">> => <<"test.token">> + }, + ?assertMatch({error, #{message := <<"Missing required fields">>, + missing_fields := [<<"jwks_uri">>]}}, + validate_token_payload_wrapper(Payload1)), + + %% Test missing resource_server_id + Payload2 = #{ + <<"jwks_uri">> => <<"https://example.com/.well-known/jwks.json">>, + <<"jwt_token">> => <<"test.token">> + }, + ?assertMatch({error, #{message := <<"Missing required fields">>, + missing_fields := [<<"resource_server_id">>]}}, + validate_token_payload_wrapper(Payload2)), + + %% Test missing jwt_token + Payload3 = #{ + <<"jwks_uri">> => <<"https://example.com/.well-known/jwks.json">>, + <<"resource_server_id">> => <<"rabbitmq">> + }, + ?assertMatch({error, #{message := <<"Missing required fields">>, + missing_fields := [<<"jwt_token">>]}}, + validate_token_payload_wrapper(Payload3)), + + %% Test missing multiple fields + Payload4 = #{ + <<"jwks_uri">> => <<"https://example.com/.well-known/jwks.json">> + }, + ?assertMatch({error, #{message := <<"Missing required fields">>, + missing_fields := Fields}}, + validate_token_payload_wrapper(Payload4)) + when length(Fields) =:= 2. + +test_validate_token_invalid_json(_) -> + %% This test would be handled at the HTTP layer, but we can test + %% the payload validation logic + EmptyPayload = #{}, + ?assertMatch({error, #{message := <<"Missing required fields">>}}, + validate_token_payload_wrapper(EmptyPayload)). + +test_validate_token_with_valid_payload(_) -> + Payload = #{ + <<"jwks_uri">> => <<"https://example.com/.well-known/jwks.json">>, + <<"resource_server_id">> => <<"rabbitmq">>, + <<"jwt_token">> => create_valid_jwt_token(), + <<"https">> => #{ + <<"verify">> => <<"verify_none">> + } + }, + + %% This should not fail on payload validation + %% The actual JWT validation might fail due to network/server issues + %% but the payload structure should be valid + Result = validate_token_payload_wrapper(Payload), + ?assert(is_tuple(Result)). + +test_validate_token_with_invalid_token(_) -> + Config = #{ + jwks_uri => <<"https://example.com/.well-known/jwks.json">>, + resource_server_id => <<"rabbitmq">>, + https_options => #{} + }, + + %% Test with completely invalid token + Result1 = validate_jwt_token_on_current_node(<<"invalid.token">>, Config), + ?assertMatch({error, #{message := _}}, Result1), + + %% Test with malformed JWT + Result2 = validate_jwt_token_on_current_node(<<"not.a.jwt">>, Config), + ?assertMatch({error, #{message := _}}, Result2). + +test_validate_token_with_expired_token(_) -> + %% Create an expired token + ExpiredToken = create_expired_jwt_token(), + Config = #{ + jwks_uri => <<"https://example.com/.well-known/jwks.json">>, + resource_server_id => <<"rabbitmq">>, + https_options => #{} + }, + + Result = validate_jwt_token_on_current_node(ExpiredToken, Config), + %% Should return error due to expired token or invalid format + ?assertMatch({error, #{message := _}} | {ok, #{valid := false}}, Result). + +test_validate_token_with_invalid_jwks_uri(_) -> + Config = #{ + jwks_uri => <<"https://invalid-domain-that-does-not-exist.com/.well-known/jwks.json">>, + resource_server_id => <<"rabbitmq">>, + https_options => #{} + }, + + Token = create_valid_jwt_token(), + Result = validate_jwt_token_on_current_node(Token, Config), + ?assertMatch({error, #{message := _}}, Result). + +test_validate_token_with_ssl_options(_) -> + Config = #{ + jwks_uri => <<"https://example.com/.well-known/jwks.json">>, + resource_server_id => <<"rabbitmq">>, + https_options => #{ + <<"cacertfile">> => <<"/path/to/ca-cert.pem">>, + <<"verify">> => <<"verify_peer">>, + <<"depth">> => 3 + } + }, + + Token = create_valid_jwt_token(), + Result = validate_jwt_token_on_current_node(Token, Config), + %% Should handle SSL options properly (may fail due to network but not SSL config) + ?assert(is_tuple(Result)). + +test_validate_token_payload_validation(_) -> + Module = rabbit_oauth2_wm_validate_token, + + %% Test check_required_fields function + ValidPayload = #{ + <<"jwks_uri">> => <<"https://example.com/.well-known/jwks.json">>, + <<"resource_server_id">> => <<"rabbitmq">>, + <<"jwt_token">> => <<"test.token">> + }, + + RequiredFields = [<<"jwks_uri">>, <<"resource_server_id">>, <<"jwt_token">>], + + %% All fields present + ?assertEqual(ok, check_required_fields_wrapper(ValidPayload, RequiredFields)), + + %% Missing one field + PartialPayload = maps:remove(<<"jwt_token">>, ValidPayload), + ?assertMatch({error, #{missing_fields := [<<"jwt_token">>]}}, + check_required_fields_wrapper(PartialPayload, RequiredFields)). + +test_extract_ssl_options_from_https(_) -> + Module = rabbit_oauth2_wm_validate_token, + + %% Test with valid HTTPS options + HttpsOptions1 = #{ + <<"cacertfile">> => <<"/path/to/ca.pem">>, + <<"certfile">> => <<"/path/to/cert.pem">>, + <<"keyfile">> => <<"/path/to/key.pem">>, + <<"verify">> => <<"verify_peer">>, + <<"depth">> => 3 + }, + + SslOptions1 = extract_ssl_options_wrapper(HttpsOptions1), + ?assert(is_list(SslOptions1)), + ?assert(lists:keymember(cacertfile, 1, SslOptions1)), + ?assert(lists:keymember(certfile, 1, SslOptions1)), + ?assert(lists:keymember(keyfile, 1, SslOptions1)), + + %% Test with empty options + SslOptions2 = extract_ssl_options_wrapper(#{}), + ?assertEqual([], SslOptions2), + + %% Test with non-map input + SslOptions3 = extract_ssl_options_wrapper(<<"not a map">>), + ?assertEqual([], SslOptions3). + +test_validate_token_with_mock_server(_) -> + %% This test would require setting up a mock JWKS server + %% For now, we'll test the structure + Config = #{ + jwks_uri => <<"http://localhost:8080/.well-known/jwks.json">>, + resource_server_id => <<"rabbitmq">>, + https_options => #{} + }, + + Token = create_valid_jwt_token(), + Result = validate_jwt_token_on_current_node(Token, Config), + %% Should return some result (likely error due to no mock server) + ?assert(is_tuple(Result)). + +test_validate_token_with_ssl_verification(_) -> + %% Test SSL verification settings + Config = #{ + jwks_uri => <<"https://example.com/.well-known/jwks.json">>, + resource_server_id => <<"rabbitmq">>, + https_options => #{ + <<"verify">> => <<"verify_peer">>, + <<"fail_if_no_peer_cert">> => true + } + }, + + Token = create_valid_jwt_token(), + Result = validate_jwt_token_on_current_node(Token, Config), + ?assert(is_tuple(Result)). + +%% +%% Helper Functions +%% + +validate_token_payload_wrapper(Payload) -> + %% This is a wrapper to test the internal validation logic + %% In a real test, we'd need to mock the peer node creation + try + RequiredFields = [<<"jwks_uri">>, <<"resource_server_id">>, <<"jwt_token">>], + case check_required_fields_wrapper(Payload, RequiredFields) of + {error, _} = Error -> Error; + ok -> + %% If validation passes, return a success indicator + {ok, payload_valid} + end + catch + E:R:S -> + {error, #{ + message => list_to_binary(io_lib:format("~p", [R])), + stacktrace => list_to_binary(io_lib:format("~p", [S])) + }} + end. + +check_required_fields_wrapper(Payload, RequiredFields) -> + check_required_fields_wrapper(Payload, RequiredFields, []). + +check_required_fields_wrapper(_Payload, [], []) -> + ok; +check_required_fields_wrapper(_Payload, [], MissingFields) -> + {error, #{ + message => <<"Missing required fields">>, + missing_fields => lists:reverse(MissingFields) + }}; +check_required_fields_wrapper(Payload, [Field | Rest], MissingFields) -> + case maps:get(Field, Payload, undefined) of + undefined -> + check_required_fields_wrapper(Payload, Rest, [Field | MissingFields]); + _ -> + check_required_fields_wrapper(Payload, Rest, MissingFields) + end. + +extract_ssl_options_wrapper(HttpsOptions) when is_map(HttpsOptions) -> + SslOptionsMap = maps:fold( + fun(Key, Value, Acc) -> + case Key of + <<"cacertfile">> -> + maps:put(cacertfile, binary_to_list(Value), Acc); + <<"certfile">> -> + maps:put(certfile, binary_to_list(Value), Acc); + <<"keyfile">> -> + maps:put(keyfile, binary_to_list(Value), Acc); + <<"verify">> -> + maps:put(verify, Value, Acc); + <<"fail_if_no_peer_cert">> -> + maps:put(fail_if_no_peer_cert, Value, Acc); + <<"depth">> -> + maps:put(depth, Value, Acc); + _ -> + Acc + end + end, + #{}, + HttpsOptions + ), + maps:to_list(SslOptionsMap); +extract_ssl_options_wrapper(_) -> + []. + +create_valid_jwt_token() -> + %% Create a basic JWT token structure (this won't be cryptographically valid) + Header = base64url:encode(rabbit_json:encode(#{ + <<"alg">> => <<"RS256">>, + <<"typ">> => <<"JWT">>, + <<"kid">> => <<"test-key">> + })), + + Now = os:system_time(seconds), + Payload = base64url:encode(rabbit_json:encode(#{ + <<"sub">> => <<"test-user">>, + <<"iss">> => <<"test-issuer">>, + <<"aud">> => <<"rabbitmq">>, + <<"exp">> => Now + 3600, %% Expires in 1 hour + <<"iat">> => Now, + <<"scope">> => <<"read write">> + })), + + Signature = base64url:encode(<<"fake-signature">>), + + <
>. + +create_expired_jwt_token() -> + %% Create a JWT token that's already expired + Header = base64url:encode(rabbit_json:encode(#{ + <<"alg">> => <<"RS256">>, + <<"typ">> => <<"JWT">>, + <<"kid">> => <<"test-key">> + })), + + Now = os:system_time(seconds), + Payload = base64url:encode(rabbit_json:encode(#{ + <<"sub">> => <<"test-user">>, + <<"iss">> => <<"test-issuer">>, + <<"aud">> => <<"rabbitmq">>, + <<"exp">> => Now - 3600, %% Expired 1 hour ago + <<"iat">> => Now - 7200, %% Issued 2 hours ago + <<"scope">> => <<"read write">> + })), + + Signature = base64url:encode(<<"fake-signature">>), + + <
>. diff --git a/deps/rabbitmq_auth_backend_oauth2/test/oauth2_validate_http_SUITE.erl b/deps/rabbitmq_auth_backend_oauth2/test/oauth2_validate_http_SUITE.erl new file mode 100644 index 000000000000..d8a8aff2f218 --- /dev/null +++ b/deps/rabbitmq_auth_backend_oauth2/test/oauth2_validate_http_SUITE.erl @@ -0,0 +1,215 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. +%% + +-module(oauth2_validate_http_SUITE). + +-compile(export_all). + +-include_lib("rabbit_common/include/rabbit.hrl"). +-include_lib("common_test/include/ct.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +all() -> + [ + {group, with_management_plugin} + ]. + +groups() -> + [ + {with_management_plugin, [], [ + test_oauth2_validate_endpoint_missing_fields, + test_oauth2_validate_endpoint_invalid_json, + test_oauth2_validate_endpoint_valid_request, + test_oauth2_validate_endpoint_unauthorized, + test_oauth2_validate_endpoint_method_not_allowed + ]} + ]. + +init_per_suite(Config) -> + rabbit_ct_helpers:log_environment(), + Config1 = rabbit_ct_helpers:set_config(Config, [ + {rmq_nodename_suffix, oauth2_validate_http}, + {rmq_nodes_count, 1} + ]), + rabbit_ct_helpers:run_setup_steps(Config1, rabbit_ct_broker_helpers:setup_steps()). + +end_per_suite(Config) -> + rabbit_ct_helpers:run_teardown_steps(Config, rabbit_ct_broker_helpers:teardown_steps()). + +init_per_group(with_management_plugin, Config) -> + rabbit_ct_broker_helpers:enable_plugin(Config, 0, rabbitmq_management), + rabbit_ct_broker_helpers:enable_plugin(Config, 0, rabbitmq_auth_backend_oauth2), + Config. + +end_per_group(with_management_plugin, Config) -> + Config. + +%% +%% Test Cases +%% + +test_oauth2_validate_endpoint_missing_fields(Config) -> + %% Test with missing required fields + Payload = #{ + <<"jwks_uri">> => <<"https://example.com/.well-known/jwks.json">>, + <<"resource_server_id">> => <<"rabbitmq">> + %% Missing jwt_token + }, + + Response = make_oauth2_validate_request(Config, Payload), + ?assertEqual(400, proplists:get_value(response_code, Response)), + + Body = proplists:get_value(response_body, Response), + ?assert(is_binary(Body)), + + case rabbit_json:try_decode(Body) of + {ok, ResponseJson} -> + ?assertEqual(<<"bad_request">>, maps:get(<<"error">>, ResponseJson)), + ?assert(maps:is_key(<<"reason">>, ResponseJson)); + {error, _} -> + %% If JSON parsing fails, at least verify it's an error response + ?assert(true) + end. + +test_oauth2_validate_endpoint_invalid_json(Config) -> + %% Test with invalid JSON + InvalidJson = <<"{invalid json}">>, + + Response = make_raw_oauth2_validate_request(Config, InvalidJson), + ?assertEqual(400, proplists:get_value(response_code, Response)). + +test_oauth2_validate_endpoint_valid_request(Config) -> + %% Test with valid request structure (may fail on JWT validation but should accept the request) + Payload = #{ + <<"jwks_uri">> => <<"https://example.com/.well-known/jwks.json">>, + <<"resource_server_id">> => <<"rabbitmq">>, + <<"jwt_token">> => create_test_jwt_token(), + <<"https">> => #{ + <<"verify">> => <<"verify_none">> + } + }, + + Response = make_oauth2_validate_request(Config, Payload), + %% Should accept the request (400 for validation errors, not 405 for method not allowed) + ResponseCode = proplists:get_value(response_code, Response), + ?assert(ResponseCode =:= 400 orelse ResponseCode =:= 200), + + %% Verify response has proper structure + Body = proplists:get_value(response_body, Response), + ?assert(is_binary(Body)), + ?assert(byte_size(Body) > 0). + +test_oauth2_validate_endpoint_unauthorized(Config) -> + %% Test without authentication + Payload = #{ + <<"jwks_uri">> => <<"https://example.com/.well-known/jwks.json">>, + <<"resource_server_id">> => <<"rabbitmq">>, + <<"jwt_token">> => create_test_jwt_token() + }, + + Response = make_oauth2_validate_request_no_auth(Config, Payload), + ?assertEqual(401, proplists:get_value(response_code, Response)). + +test_oauth2_validate_endpoint_method_not_allowed(Config) -> + %% Test with GET method (should only accept POST) + Response = make_oauth2_validate_get_request(Config), + ?assertEqual(405, proplists:get_value(response_code, Response)). + +%% +%% Helper Functions +%% + +make_oauth2_validate_request(Config, Payload) -> + make_oauth2_validate_request(Config, Payload, "guest", "guest"). + +make_oauth2_validate_request(Config, Payload, Username, Password) -> + Json = rabbit_json:encode(Payload), + make_raw_oauth2_validate_request(Config, Json, Username, Password). + +make_oauth2_validate_request_no_auth(Config, Payload) -> + Json = rabbit_json:encode(Payload), + make_raw_oauth2_validate_request_no_auth(Config, Json). + +make_raw_oauth2_validate_request(Config, Body) -> + make_raw_oauth2_validate_request(Config, Body, "guest", "guest"). + +make_raw_oauth2_validate_request(Config, Body, Username, Password) -> + Port = rabbit_ct_broker_helpers:get_node_config(Config, 0, tcp_port_mgmt), + URI = lists:flatten(io_lib:format("http://localhost:~w/api/oauth2/validate/token/decode", [Port])), + + Headers = [ + {"Content-Type", "application/json"}, + {"Authorization", "Basic " ++ base64:encode_to_string(Username ++ ":" ++ Password)} + ], + + case httpc:request(post, {URI, Headers, "application/json", Body}, [], []) of + {ok, {{_Version, ResponseCode, _ReasonPhrase}, _Headers, ResponseBody}} -> + [ + {response_code, ResponseCode}, + {response_body, list_to_binary(ResponseBody)} + ]; + {error, Reason} -> + ct:fail("HTTP request failed: ~p", [Reason]) + end. + +make_raw_oauth2_validate_request_no_auth(Config, Body) -> + Port = rabbit_ct_broker_helpers:get_node_config(Config, 0, tcp_port_mgmt), + URI = lists:flatten(io_lib:format("http://localhost:~w/api/oauth2/validate/token/decode", [Port])), + + Headers = [ + {"Content-Type", "application/json"} + ], + + case httpc:request(post, {URI, Headers, "application/json", Body}, [], []) of + {ok, {{_Version, ResponseCode, _ReasonPhrase}, _Headers, ResponseBody}} -> + [ + {response_code, ResponseCode}, + {response_body, list_to_binary(ResponseBody)} + ]; + {error, Reason} -> + ct:fail("HTTP request failed: ~p", [Reason]) + end. + +make_oauth2_validate_get_request(Config) -> + Port = rabbit_ct_broker_helpers:get_node_config(Config, 0, tcp_port_mgmt), + URI = lists:flatten(io_lib:format("http://localhost:~w/api/oauth2/validate/token/decode", [Port])), + + Headers = [ + {"Authorization", "Basic " ++ base64:encode_to_string("guest:guest")} + ], + + case httpc:request(get, {URI, Headers}, [], []) of + {ok, {{_Version, ResponseCode, _ReasonPhrase}, _Headers, ResponseBody}} -> + [ + {response_code, ResponseCode}, + {response_body, list_to_binary(ResponseBody)} + ]; + {error, Reason} -> + ct:fail("HTTP request failed: ~p", [Reason]) + end. + +create_test_jwt_token() -> + %% Create a basic JWT token structure for testing + Header = base64url:encode(rabbit_json:encode(#{ + <<"alg">> => <<"RS256">>, + <<"typ">> => <<"JWT">>, + <<"kid">> => <<"test-key">> + })), + + Now = os:system_time(seconds), + Payload = base64url:encode(rabbit_json:encode(#{ + <<"sub">> => <<"test-user">>, + <<"iss">> => <<"test-issuer">>, + <<"aud">> => <<"rabbitmq">>, + <<"exp">> => Now + 3600, + <<"iat">> => Now, + <<"scope">> => <<"read write">> + })), + + Signature = base64url:encode(<<"fake-signature">>), + + <
>.