Skip to content

Commit 2c9ddc7

Browse files
Add signing introspected token
1 parent 6d0c409 commit 2c9ddc7

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,
@@ -51,7 +51,7 @@ refresh_access_token(OAuthProvider, Request) ->
5151
parse_access_token_response(Response).
5252

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

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

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