Skip to content

Commit 9b275c7

Browse files
Add signing introspected token
1 parent d68ebfe commit 9b275c7

File tree

9 files changed

+306
-24
lines changed

9 files changed

+306
-24
lines changed

deps/oauth2_client/include/types.hrl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,12 @@
9595
}).
9696

9797
-type unsuccessful_introspect_token_response() :: #unsuccessful_introspect_token_response{}.
98+
99+
-record(signing_key, {
100+
id :: string(),
101+
type :: hs256 | rs256,
102+
key :: option(binary()),
103+
private_key :: option(binary()),
104+
public_key :: option(binary())
105+
}).
106+
-type signing_key() :: #signing_key{}.

deps/oauth2_client/src/oauth2_client.erl

Lines changed: 182 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
-module(oauth2_client).
88
-export([get_access_token/2, get_expiration_time/1,
99
refresh_access_token/2,
10-
introspect_token/1,
10+
introspect_token/1,sign_token/1,
1111
get_oauth_provider/1, get_oauth_provider/2,
1212
get_openid_configuration/2,
1313
build_openid_discovery_endpoint/3,
@@ -50,7 +50,7 @@ refresh_access_token(OAuthProvider, Request) ->
5050
parse_access_token_response(Response).
5151

5252
-spec introspect_token(binary()) ->
53-
{ok, map()} |
53+
{ok, binary()} |
5454
{error, unsuccessful_access_token_response() | any()}.
5555
introspect_token(Token) ->
5656
case build_introspection_request() of
@@ -67,10 +67,56 @@ introspect_token(Token) ->
6767
rabbit_log:debug("Sending introspect_request URL:~p Header: ~p Body: ~p",
6868
[URL, Header, Body]),
6969
Response = httpc:request(post, {URL, Header, Type, Body}, HTTPOptions, Options),
70-
parse_introspect_token_response(Response);
70+
case parse_introspect_token_response(Response) of
71+
{error, _} = Error -> Error;
72+
{ok, _} = Ret -> Ret
73+
end;
7174
{error, _} = Error -> Error
7275
end.
7376

77+
sign_token(TokenPayload) ->
78+
case get_opaque_token_signing_key() of
79+
{error, _} = Error -> Error;
80+
SK ->
81+
ct:log("Signing with ~p", [SK]),
82+
case SK#signing_key.type of
83+
hs256 ->
84+
{_, Value} = sign_token_hs(TokenPayload, SK#signing_key.key, SK#signing_key.id),
85+
{ok, Value};
86+
_ -> {error, not_implemented}
87+
end
88+
end.
89+
90+
sign_token_hs(Token, #{<<"kid">> := TokenKey} = Jwk) ->
91+
sign_token_hs(Token, Jwk, TokenKey).
92+
93+
%%sign_token_hs(Token, Jwk, TokenKey) ->
94+
%% sign_token_hs(Token, Jwk, TokenKey, true).
95+
96+
sign_token_hs(Token, Jwk, TokenKey) ->
97+
Jws0 = #{
98+
<<"alg">> => <<"HS256">>,
99+
<<"kid">> => TokenKey
100+
},
101+
Jws = maps:put(<<"kid">>, TokenKey, Jws0),
102+
sign_token(Token, Jwk, Jws).
103+
104+
sign_token_rsa(Token, Jwk, TokenKey) ->
105+
Jws = #{
106+
<<"alg">> => <<"RS256">>,
107+
<<"kid">> => TokenKey
108+
},
109+
sign_token(Token, Jwk, Jws).
110+
111+
sign_token_no_kid(Token, Jwk) ->
112+
Signed = jose_jwt:sign(Jwk, Token),
113+
jose_jws:compact(Signed).
114+
115+
sign_token(Token, Jwk, Jws) ->
116+
Signed = jose_jwt:sign(Jwk, Jws, Token),
117+
jose_jws:compact(Signed).
118+
119+
74120
build_introspect_request_parameters(Token, #introspect_token_request{
75121
client_auth_method = Method,
76122
client_id = ClientId,
@@ -374,6 +420,63 @@ unlock(LockId) ->
374420
end
375421
end.
376422

423+
-spec get_opaque_token_signing_key() -> {ok, signing_key()} | {error, any()}.
424+
get_opaque_token_signing_key() ->
425+
case get_env(opaque_token_signing_key) of
426+
undefined -> {error, missing_opaque_token_signing_key};
427+
Map ->
428+
parse_signing_key_configuration(Map)
429+
end.
430+
431+
parse_signing_key_configuration(Map) ->
432+
SK0 = case maps:get(id, Map, undefined) of
433+
undefined -> {error, missing_signing_key_id};
434+
Id -> #signing_key{id = Id}
435+
end,
436+
case {SK0, maps:get(type, Map, hs256)} of
437+
{{error, _} = Error, _} ->
438+
Error;
439+
{_, hs256} ->
440+
Sk1 = case maps:get(key, Map, undefined) of
441+
undefined -> {error, missing_symmetrical_key_value};
442+
SymKey -> SK0#signing_key{
443+
type = hs256,
444+
key = case make_jwk(#{
445+
<<"alg">> => <<"HS256">>,
446+
<<"value">> => SymKey,
447+
<<"kty">> => <<"MAC">>,
448+
<<"use">> => <<"sig">>}) of
449+
{error, _} = Error -> Error;
450+
{ok, Val} -> Val
451+
end
452+
}
453+
end,
454+
case Sk1#signing_key.key of
455+
{error, _} = Error1 -> Error1;
456+
_ -> Sk1
457+
end;
458+
{_, rs256} ->
459+
Sk2 = case maps:get(key_pem_file, Map, undefined) of
460+
undefined ->
461+
{error, missing_key_pem_file};
462+
PrivateKey ->
463+
case maps:get(cert_pem_file, Map, undefined) of
464+
undefined ->
465+
{error, missing_cert_pem_file};
466+
PublicKey ->
467+
SK0#signing_key{type = hs256,
468+
private_key = PrivateKey,
469+
public_key = PublicKey}
470+
end
471+
end,
472+
case {Sk2#signing_key.private_key, Sk2#signing_key.public_key} of
473+
{{error, _} = Error2, _} -> Error2;
474+
{_, {error, _} = Error3} -> Error3;
475+
{_, _} -> Sk2
476+
end;
477+
{_, _} -> {error, unsupported_signing_type}
478+
end.
479+
377480
-spec get_oauth_provider(list()) -> {ok, oauth_provider()} | {error, any()}.
378481
get_oauth_provider(ListOfRequiredAttributes) ->
379482
case get_env(default_oauth_provider) of
@@ -819,3 +922,79 @@ get_env(Par, Def) ->
819922
application:get_env(rabbitmq_auth_backend_oauth2, Par, Def).
820923
set_env(Par, Val) ->
821924
application:set_env(rabbitmq_auth_backend_oauth2, Par, Val).
925+
926+
927+
-include_lib("jose/include/jose_jwk.hrl").
928+
929+
-spec make_jwk(binary() | map()) -> {ok, #{binary() => binary()}} | {error, term()}.
930+
make_jwk(Json) when is_binary(Json); is_list(Json) ->
931+
JsonMap = jose:decode(iolist_to_binary(Json)),
932+
make_jwk(JsonMap);
933+
934+
make_jwk(JsonMap) when is_map(JsonMap) ->
935+
case JsonMap of
936+
#{<<"kty">> := <<"MAC">>, <<"value">> := _Value} ->
937+
{ok, mac_to_oct(JsonMap)};
938+
#{<<"kty">> := <<"RSA">>, <<"n">> := _N, <<"e">> := _E} ->
939+
{ok, fix_alg(JsonMap)};
940+
#{<<"kty">> := <<"oct">>, <<"k">> := _K} ->
941+
{ok, fix_alg(JsonMap)};
942+
#{<<"kty">> := <<"OKP">>, <<"crv">> := _Crv, <<"x">> := _X} ->
943+
{ok, fix_alg(JsonMap)};
944+
#{<<"kty">> := <<"EC">>} ->
945+
{ok, fix_alg(JsonMap)};
946+
#{<<"kty">> := Kty} when Kty == <<"oct">>;
947+
Kty == <<"MAC">>;
948+
Kty == <<"RSA">>;
949+
Kty == <<"OKP">>;
950+
Kty == <<"EC">> ->
951+
{error, {fields_missing_for_kty, Kty}};
952+
#{<<"kty">> := _Kty} ->
953+
{error, unknown_kty};
954+
#{} ->
955+
{error, no_kty}
956+
end.
957+
958+
from_pem(Pem) ->
959+
case jose_jwk:from_pem(Pem) of
960+
#jose_jwk{} = Jwk -> {ok, Jwk};
961+
Other ->
962+
error_logger:warning_msg("Error parsing jwk from pem: ", [Other]),
963+
{error, invalid_pem_string}
964+
end.
965+
966+
from_pem_file(FileName) ->
967+
case filelib:is_file(FileName) of
968+
false ->
969+
{error, enoent};
970+
true ->
971+
case jose_jwk:from_pem_file(FileName) of
972+
#jose_jwk{} = Jwk -> {ok, Jwk};
973+
Other ->
974+
error_logger:warning_msg("Error parsing jwk from pem file: ", [Other]),
975+
{error, invalid_pem_file}
976+
end
977+
end.
978+
979+
mac_to_oct(#{<<"kty">> := <<"MAC">>, <<"value">> := Value} = Key) ->
980+
OktKey = maps:merge(Key,
981+
#{<<"kty">> => <<"oct">>,
982+
<<"k">> => base64url:encode(Value)}),
983+
fix_alg(OktKey).
984+
985+
fix_alg(#{<<"alg">> := Alg} = Key) ->
986+
Algs = uaa_algs(),
987+
case maps:get(Alg, Algs, undefined) of
988+
undefined -> Key;
989+
Val -> Key#{<<"alg">> := Val}
990+
end;
991+
fix_alg(#{} = Key) -> Key.
992+
993+
uaa_algs() ->
994+
UaaEnv = application:get_env(rabbitmq_auth_backend_oauth2, uaa_jwt_decoder, []),
995+
DefaultAlgs = #{<<"HMACSHA256">> => <<"HS256">>,
996+
<<"HMACSHA384">> => <<"HS384">>,
997+
<<"HMACSHA512">> => <<"HS512">>,
998+
<<"SHA256withRSA">> => <<"RS256">>,
999+
<<"SHA512withRSA">> => <<"RS512">>},
1000+
proplists:get_value(uaa_algs, UaaEnv, DefaultAlgs).

deps/oauth2_client/test/system_SUITE.erl

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ groups() ->
4949
cannot_introspect_due_to_missing_configuration,
5050
{https, [], [
5151
{with_introspection_basic_client_credentials, [], [
52-
can_introspect_token
52+
can_introspect_token
5353
]},
5454
{with_introspection_request_param_client_credentials, [], [
5555
can_introspect_token
@@ -192,6 +192,13 @@ init_per_group(with_default_oauth_provider, Config) ->
192192
OAuthProvider#oauth_provider.id),
193193
Config;
194194

195+
init_per_group(with_hs256_signing, Config) ->
196+
application:set_env(rabbitmq_auth_backend_oauth2, opaque_token_signing_key,
197+
#{ id => <<"some-id">>,
198+
type => hs256,
199+
key => <<"some-key-value">> }),
200+
Config;
201+
195202
init_per_group(with_introspection_endpoint, Config) ->
196203
application:set_env(rabbitmq_auth_backend_oauth2, introspection_endpoint,
197204
build_token_introspection_endpoint("https")),
@@ -743,7 +750,9 @@ cannot_introspect_due_to_missing_configuration(_Config)->
743750
application:unset_env(rabbitmq_auth_backend_oauth2, introspection_client_secret).
744751

745752
can_introspect_token(_Config) ->
746-
{ok, _} = oauth2_client:introspect_token(?MOCK_OPAQUE_TOKEN).
753+
{ok, Value} = oauth2_client:introspect_token(?MOCK_OPAQUE_TOKEN),
754+
ct:log("JWT : ~p", [Value]),
755+
ok.
747756

748757
introspected_token_is_not_active(_Config) ->
749758
{error, introspected_token_not_valid} = oauth2_client:introspect_token(?MOCK_OPAQUE_TOKEN).
@@ -807,6 +816,8 @@ build_https_oauth_provider(Id, CaCertFile) ->
807816
jwks_uri = build_jwks_uri("https"),
808817
ssl_options = ssl_options(verify_peer, false, CaCertFile)
809818
}.
819+
oauth_provider_to_proplist(undefined) -> [];
820+
810821
oauth_provider_to_proplist(#oauth_provider{
811822
issuer = Issuer,
812823
token_endpoint = TokenEndpoint,

deps/oauth2_client/test/unit_SUITE.erl

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@ all() ->
2424
build_openid_discovery_endpoint,
2525
{group, ssl_options},
2626
{group, merge},
27-
{group, get_expiration_time}
27+
{group, get_expiration_time},
28+
{group, sign_token}
2829
].
2930

3031
groups() ->
3132
[
33+
{sign_token, [], [
34+
can_sign_token
35+
]},
3236
{ssl_options, [], [
3337
no_ssl_options_triggers_verify_peer,
3438
choose_verify_over_peer_verification,
@@ -298,3 +302,12 @@ access_token_response_without_expiration_time(_) ->
298302
},
299303
ct:log("AccessTokenResponse ~p", [AccessTokenResponse]),
300304
?assertEqual({error, missing_exp_field}, oauth2_client:get_expiration_time(AccessTokenResponse)).
305+
306+
307+
can_sign_token(_Config) ->
308+
application:set_env(rabbitmq_auth_backend_oauth2, opaque_token_signing_key,
309+
#{ id => <<"key-id">>, type => hs256, key => <<"some-key">>}),
310+
311+
{ok, Value } = oauth2_client:sign_token(#{"scopes" => "a b"}),
312+
ct:log("JWT : ~p", [Value]),
313+
ok.

deps/rabbitmq_auth_backend_oauth2/priv/schema/rabbitmq_auth_backend_oauth2.schema

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,67 @@
122122
[list_to_binary(V) || {_, V} <- lists:reverse(Settings)]
123123
end}.
124124

125+
%% Signing key used by RabbitMQ to convert an introspected opaque token
126+
%% into a JWT token for the management UI use case. For messaging
127+
%% use cases like AMQP it is not necessary because when a user authenticates with
128+
%% an opaque token, RabbitMQ introspects the opaque token to obtain the actual
129+
%% JWT's payload with the scopes and those scopes are kept along with the connection
130+
%% in RabbitMQ's memory for as long as the connection stays alive, exactly as if the user
131+
%% would have presented a JWT from the beginning.
132+
%% For the management UI use case, there is no server-side state
133+
%% hence the management UI has to convert an opaque token into a
134+
%% JWT token so that RabbitMQ can validate the JWT token without making
135+
%% an external HTTP request to the introspection endpoint. If the management ui
136+
%% sends an opaque token, RabbitMQ, in order to validate the token, has to
137+
%% introspect it.
138+
%%
139+
%% Here it is how this signing key is used:
140+
%% When a management user logs in for the first time with an
141+
%% opaque token, the token is instrospected and validated it.
142+
%% If the token is valid, RabbitMQ issues a JWT token whose payload
143+
%% is the introspected token and the signing key is configured in
144+
%% `auth_oauth2.opaque_token_signing_key`.
145+
%%
146+
%% The issued JWT token has the same expiry date, scopes, etc as the original
147+
%% opaque token. In other words, RabbitMQ does not add anything.
148+
%% It only wraps it with a digital signature.
149+
%%
150+
%% Maybe it is necessary a rabbitmqctl command to rotate the signing key like:
151+
%% rabbitmqctl rotate_opaque_token_signing_key <new_id> <type> [<value> | <key_pem_file> <cert_pem_file>]
152+
%%
153+
%% Note: This feature is only necessary when the management UI needs to authenticate with OAuth2
154+
%% using opaque tokens. In all other cases, this feature is not necessary.
155+
%%
156+
%% Example:
157+
%% auth_oauth2.opaque_token_signing_key.id = rabbit_kid
158+
%% for symmetrical key
159+
%% auth_oauth2.opaque_token_signing_key.type = HS256
160+
%% auth_oauth2.opaque_token_signing_key.key = "hello-world-symmetrical-key"
161+
%% for asymmetrical key
162+
%% auth_oauth2.opaque_token_signing_key.type = RS256
163+
%% auth_oauth2.opaque_token_signing_key.key_pem_file = rabbit_key.pem
164+
%% auth_oauth2.opaque_token_signing_key.cert_pem_file = rabbit_cert.pem
165+
166+
{mapping, "auth_oauth2.opaque_token_signing_key.id",
167+
"rabbitmq_auth_backend_oauth2.key_config.opaque_token_signing_key.id",
168+
[{datatype, string}]}.
169+
170+
{mapping, "auth_oauth2.opaque_token_signing_key.type",
171+
"rabbitmq_auth_backend_oauth2.key_config.opaque_token_signing_key.type",
172+
[{datatype, {enum, [HS256, RS256]}}]}.
173+
174+
{mapping, "auth_oauth2.opaque_token_signing_key.key",
175+
"rabbitmq_auth_backend_oauth2.key_config.opaque_token_signing_key.key",
176+
[{datatype, string}]}.
177+
178+
{mapping, "auth_oauth2.opaque_token_signing_key.key_file",
179+
"rabbitmq_auth_backend_oauth2.key_config.opaque_token_signing_key.key_pem_file",
180+
[{datatype, file}, {validators, ["file_accessible"]}]}.
181+
182+
{mapping, "auth_oauth2.opaque_token_signing_key.cert_file",
183+
"rabbitmq_auth_backend_oauth2.key_config.opaque_token_signing_key.cert_pem_file",
184+
[{datatype, file}, {validators, ["file_accessible"]}]}.
185+
125186

126187

127188
%% ID of the default signing key

deps/rabbitmq_management/priv/schema/rabbitmq_management.schema

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,6 @@ end}.
470470
{mapping, "management.oauth_provider_url", "rabbitmq_management.oauth_provider_url",
471471
[{datatype, string}]}.
472472

473-
474473
%% Your client application's identifier as registered with the OIDC/OAuth2
475474
{mapping, "management.oauth_client_id", "rabbitmq_management.oauth_client_id",
476475
[{datatype, string}]}.

0 commit comments

Comments
 (0)