ED-46: Refactor authdata aquisition and creation code (#7)

This commit is contained in:
Alexey 2021-03-23 19:05:00 +03:00 committed by GitHub
parent 40ddb4ef26
commit 18286e30be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 614 additions and 350 deletions

View File

@ -40,23 +40,48 @@
% service => {erl_health, service , [<<"bouncer">>]}
}},
{tokens, #{
jwt => #{
{jwt, #{
keyset => #{
test => #{
source => {pem_file, "keys/local/private.pem"},
metadata => #{
authority => <<"test.rbkmoney.keycloak">>,
metadata_ns => <<"test.rbkmoney.token-keeper">>,
auth_method => detect,
user_realm => <<"external">>
}
}
%% Who is the authority for this token
authority => keycloak
}
}
}},
{user_session_token_origins, [<<"http://localhost">>]}
%% Authority configuration
{authorities, #{
keycloak => #{
%% What to use as authority id string
id => <<"test.rbkmoney.keycloak">>,
%% Where to fetch authdata for tokens issued by this authority
authdata_sources => [
%% Fetch from service storage
storage,
%% Extract bouncer context from the token itself and make up the rest
{extract, #{
%% Configuration for how to extract said context
methods => [
%% Extract bouncer context from claim
claim,
%% Create bouncer context from various legacy data the token has to offer
%% Avaiable options are: `phony_api_key`, `user_session_token`, `detect_token`
%% `user_session_token` requires the `user_realm` option to be set
%% `detect_token` tries to determine wether the token is an
%% `phony_api_key` or `user_session_token` based on token's source context and
%% `user_session_token_origins` option
{detect_token, #{
user_session_token_origins => [<<"http://localhost">>],
user_realm => <<"external">>
}}
],
%% What to use as metadata namespace
metadata_ns => <<"test.rbkmoney.token-keeper">>
}}
]
}
}}
]},

View File

@ -240,10 +240,11 @@ extract_opt_meta(K, Metadata, EncodeFun, Acc) ->
error -> Acc
end.
encode_token({JTI, Claims, TokenMetadata}) ->
encode_token({JTI, Claims, Authority, TokenMetadata}) ->
#{
jti => JTI,
claims => Claims,
authority => Authority,
metadata => TokenMetadata
}.

View File

@ -0,0 +1,46 @@
-module(tk_authdata_source).
%% Behaviour
-callback get_authdata(tk_token_jwt:t(), source_opts()) -> stored_authdata() | undefined.
%% API functions
-export([get_authdata/3]).
%% API Types
-type authdata_source() :: token_source() | {token_source(), source_opts()}.
-type token_source() :: storage | extractor.
-type source_opts() :: #{
methods => tk_context_extractor:methods(),
metadata_ns => binary()
}.
-type stored_authdata() :: #{
id => tk_authority:id(),
status := tk_authority:status(),
context := tk_authority:encoded_context_fragment(),
metadata => tk_authority:metadata()
}.
-export_type([authdata_source/0]).
-export_type([token_source/0]).
-export_type([source_opts/0]).
-export_type([stored_authdata/0]).
%% API functions
-spec get_authdata(token_source(), tk_token_jwt:t(), source_opts()) -> stored_authdata() | undefined.
get_authdata(Source, Token, Opts) ->
Hander = get_source_handler(Source),
Hander:get_authdata(Token, Opts).
%%
get_source_handler(storage) ->
tk_authdata_source_storage;
get_source_handler(extract) ->
tk_authdata_source_extractor.

View File

@ -0,0 +1,71 @@
-module(tk_authdata_source_extractor).
-behaviour(tk_authdata_source).
-include_lib("token_keeper_proto/include/tk_context_thrift.hrl").
%% Behaviour
-export([get_authdata/2]).
%% Behaviour functions
-spec get_authdata(tk_token_jwt:t(), tk_authdata_source:source_opts()) ->
tk_authdata_source:stored_authdata() | undefined.
get_authdata(Token, Opts) ->
Methods = get_extractor_methods(Opts),
case extract_context_with(Methods, Token) of
{Context, Metadata} ->
make_auth_data(Context, Metadata, Opts);
undefined ->
undefined
end.
%%
get_extractor_methods(Opts) ->
maps:get(methods, Opts).
extract_context_with([], _Token) ->
undefined;
extract_context_with([MethodOpts | Rest], Token) ->
{Method, Opts} = get_method_opts(MethodOpts),
case tk_context_extractor:get_context(Method, Token, Opts) of
AuthData when AuthData =/= undefined ->
AuthData;
undefined ->
extract_context_with(Rest, Token)
end.
make_auth_data(ContextFragment, Metadata, SourceOpts) ->
genlib_map:compact(#{
status => active,
context => encode_context_fragment(ContextFragment),
metadata => wrap_metadata(Metadata, SourceOpts)
}).
wrap_metadata(undefined, _SourceOpts) ->
undefined;
wrap_metadata(Metadata, SourceOpts) ->
MetadataNS = maps:get(metadata_ns, SourceOpts),
#{MetadataNS => Metadata}.
encode_context_fragment({encoded_context_fragment, ContextFragment}) ->
ContextFragment;
encode_context_fragment(ContextFragment) ->
#bctx_ContextFragment{
type = v1_thrift_binary,
content = encode_context_fragment_content(ContextFragment)
}.
encode_context_fragment_content(ContextFragment) ->
Type = {struct, struct, {bouncer_context_v1_thrift, 'ContextFragment'}},
Codec = thrift_strict_binary_codec:new(),
case thrift_strict_binary_codec:write(Codec, Type, ContextFragment) of
{ok, Codec1} ->
thrift_strict_binary_codec:close(Codec1)
end.
get_method_opts({_Method, _Opts} = MethodOpts) ->
MethodOpts;
get_method_opts(Method) when is_atom(Method) ->
{Method, #{}}.

View File

@ -0,0 +1,13 @@
-module(tk_authdata_source_storage).
-behaviour(tk_authdata_source).
%% Behaviour
-export([get_authdata/2]).
%% Behaviour functions
-spec get_authdata(tk_token_jwt:t(), tk_authdata_source:source_opts()) -> undefined.
get_authdata(_Token, _Opts) ->
%@TODO: This is for when we actually have storage for authdata
undefined.

82
src/tk_authority.erl Normal file
View File

@ -0,0 +1,82 @@
-module(tk_authority).
-include_lib("token_keeper_proto/include/tk_context_thrift.hrl").
-include_lib("token_keeper_proto/include/tk_token_keeper_thrift.hrl").
%% API functions
-export([get_authdata_by_token/2]).
%% API Types
-type authority() :: #{
id := autority_id(),
authdata_sources := [tk_authdata_source:authdata_source()]
}.
-type authdata() :: #{
id => id(),
status := status(),
context := encoded_context_fragment(),
authority := autority_id(),
metadata => metadata()
}.
-type id() :: binary().
-type status() :: active | revoked.
-type encoded_context_fragment() :: tk_context_thrift:'ContextFragment'().
-type metadata() :: #{metadata_ns() => #{binary() => binary()}}.
-type metadata_ns() :: binary().
-type autority_id() :: binary().
-export_type([authority/0]).
-export_type([authdata/0]).
-export_type([id/0]).
-export_type([status/0]).
-export_type([encoded_context_fragment/0]).
-export_type([metadata/0]).
-export_type([metadata_ns/0]).
-export_type([autority_id/0]).
%% API Functions
-spec get_authdata_by_token(tk_token_jwt:t(), authority()) ->
{ok, authdata()} | {error, {authdata_not_found, _Sources}}.
get_authdata_by_token(Token, Authority) ->
AuthDataSources = get_auth_data_sources(Authority),
case get_authdata_from_sources(AuthDataSources, Token) of
AuthData when AuthData =/= undefined ->
{ok, add_authority_id(AuthData, Authority)};
undefined ->
{error, {authdata_not_found, AuthDataSources}}
end.
%%
get_auth_data_sources(Authority) ->
case maps:get(authdata_sources, Authority, undefined) of
Sources when Sources =/= undefined ->
Sources;
undefined ->
throw({misconfiguration, {no_authdata_sources, Authority}})
end.
get_authdata_from_sources([], _Token) ->
undefined;
get_authdata_from_sources([SourceOpts | Rest], Token) ->
{Source, Opts} = get_source_opts(SourceOpts),
case tk_authdata_source:get_authdata(Source, Token, Opts) of
AuthData when AuthData =/= undefined ->
AuthData;
undefined ->
get_authdata_from_sources(Rest, Token)
end.
add_authority_id(AuthData, Authority) ->
AuthData#{authority => maps:get(id, Authority)}.
get_source_opts({_Source, _Opts} = SourceOpts) ->
SourceOpts;
get_source_opts(Source) when is_atom(Source) ->
{Source, #{}}.

View File

@ -1,151 +0,0 @@
-module(tk_bouncer_context).
-include_lib("token_keeper_proto/include/tk_context_thrift.hrl").
-export([extract_context_fragment/2]).
-type encoded_context_fragment() :: tk_context_thrift:'ContextFragment'().
%%
-spec extract_context_fragment(tk_token_jwt:t(), token_keeper:token_type()) -> encoded_context_fragment() | undefined.
extract_context_fragment(TokenInfo, TokenType) ->
extract_context_fragment([claim, metadata], TokenInfo, TokenType).
extract_context_fragment([Method | Rest], TokenInfo, TokenType) ->
case extract_context_fragment_by(Method, TokenInfo, TokenType) of
Fragment when Fragment =/= undefined ->
Fragment;
undefined ->
extract_context_fragment(Rest, TokenInfo, TokenType)
end;
extract_context_fragment([], _, _) ->
undefined.
%%
extract_context_fragment_by(claim, TokenInfo, _TokenType) ->
% TODO
% We deliberately do not handle decoding errors here since we extract claims from verified
% tokens only, hence they must be well-formed here.
Claims = tk_token_jwt:get_claims(TokenInfo),
case get_claim(Claims) of
{ok, ClaimFragment} ->
ClaimFragment;
undefined ->
undefined
end;
extract_context_fragment_by(metadata, TokenInfo, TokenType) ->
case tk_token_jwt:get_metadata(TokenInfo) of
#{auth_method := detect} ->
AuthMethod = get_auth_method(TokenType),
build_auth_context_fragment(AuthMethod, TokenInfo);
#{auth_method := AuthMethod} ->
build_auth_context_fragment(AuthMethod, TokenInfo);
#{} ->
undefined
end.
get_auth_method(TokenType) ->
TokenType.
-spec build_auth_context_fragment(
tk_token_jwt:auth_method(),
tk_token_jwt:t()
) -> encoded_context_fragment().
build_auth_context_fragment(api_key_token, TokenInfo) ->
UserID = tk_token_jwt:get_subject_id(TokenInfo),
Acc0 = bouncer_context_helpers:empty(),
Acc1 = bouncer_context_helpers:add_auth(
#{
method => <<"ApiKeyToken">>,
token => #{id => tk_token_jwt:get_token_id(TokenInfo)},
scope => [#{party => #{id => UserID}}]
},
Acc0
),
encode_context_fragment(Acc1);
build_auth_context_fragment(user_session_token, TokenInfo) ->
Metadata = tk_token_jwt:get_metadata(TokenInfo),
UserID = tk_token_jwt:get_subject_id(TokenInfo),
Expiration = tk_token_jwt:get_expires_at(TokenInfo),
Acc0 = bouncer_context_helpers:empty(),
Acc1 = bouncer_context_helpers:add_user(
#{
id => UserID,
email => tk_token_jwt:get_subject_email(TokenInfo),
realm => #{id => maps:get(user_realm, Metadata, undefined)}
},
Acc0
),
Acc2 = bouncer_context_helpers:add_auth(
#{
method => <<"SessionToken">>,
expiration => make_auth_expiration(Expiration),
token => #{id => tk_token_jwt:get_token_id(TokenInfo)}
},
Acc1
),
encode_context_fragment(Acc2).
make_auth_expiration(Timestamp) when is_integer(Timestamp) ->
genlib_rfc3339:format(Timestamp, second);
make_auth_expiration(unlimited) ->
undefined.
%%
-define(CLAIM_BOUNCER_CTX, <<"bouncer_ctx">>).
-define(CLAIM_CTX_TYPE, <<"ty">>).
-define(CLAIM_CTX_CONTEXT, <<"ct">>).
-define(CLAIM_CTX_TYPE_V1_THRIFT_BINARY, <<"v1_thrift_binary">>).
-type claim() :: tk_token_jwt:claim().
-type claims() :: tk_token_jwt:claims().
-spec get_claim(claims()) ->
{ok, encoded_context_fragment()} | {error, {unsupported, claim()} | {malformed, binary()}} | undefined.
get_claim(Claims) ->
case maps:get(?CLAIM_BOUNCER_CTX, Claims, undefined) of
Claim when Claim /= undefined ->
decode_claim(Claim);
undefined ->
undefined
end.
-spec decode_claim(claim()) ->
{ok, encoded_context_fragment()} | {error, {unsupported, claim()} | {malformed, binary()}}.
decode_claim(#{
?CLAIM_CTX_TYPE := ?CLAIM_CTX_TYPE_V1_THRIFT_BINARY,
?CLAIM_CTX_CONTEXT := Content
}) ->
try
{ok, #bctx_ContextFragment{
type = v1_thrift_binary,
content = base64:decode(Content)
}}
catch
% NOTE
% The `base64:decode/1` fails in unpredictable ways.
error:_ ->
{error, {malformed, Content}}
end;
decode_claim(Ctx) ->
{error, {unsupported, Ctx}}.
%%
encode_context_fragment(ContextFragment) ->
#bctx_ContextFragment{
type = v1_thrift_binary,
content = encode_context_fragment_content(ContextFragment)
}.
encode_context_fragment_content(ContextFragment) ->
Type = {struct, struct, {bouncer_context_v1_thrift, 'ContextFragment'}},
Codec = thrift_strict_binary_codec:new(),
case thrift_strict_binary_codec:write(Codec, Type, ContextFragment) of
{ok, Codec1} ->
thrift_strict_binary_codec:close(Codec1)
end.

View File

@ -0,0 +1,51 @@
-module(tk_context_extractor).
%% Behaviour
-callback get_context(tk_token_jwt:t(), extractor_opts()) -> extracted_context() | undefined.
%% API functions
-export([get_context/3]).
%% API Types
-type methods() :: [{method(), extractor_opts()} | method()].
-type method() :: claim | detect_token | api_key_token | user_session_token.
-type extractor_opts() :: #{
user_session_token_origins => list(binary()),
user_realm => binary()
}.
-type extracted_context() :: {context_fragment(), tk_authority:metadata() | undefined}.
-type encoded_context_fragment() :: tk_context_thrift:'ContextFragment'().
-type context_fragment() ::
{encoded_context_fragment, encoded_context_fragment()}
| bouncer_context_helpers:context_fragment().
-export_type([methods/0]).
-export_type([method/0]).
-export_type([extractor_opts/0]).
-export_type([extracted_context/0]).
-export_type([encoded_context_fragment/0]).
-export_type([context_fragment/0]).
%% API functions
-spec get_context(method(), tk_token_jwt:t(), extractor_opts()) -> extracted_context() | undefined.
get_context(Method, Token, Opts) ->
Hander = get_extractor_handler(Method),
Hander:get_context(Token, Opts).
%%
get_extractor_handler(claim) ->
tk_extractor_claim;
get_extractor_handler(detect_token) ->
tk_extractor_detect_token;
get_extractor_handler(phony_api_key) ->
tk_extractor_phony_api_key;
get_extractor_handler(user_session_token) ->
tk_extractor_user_session_token.

View File

@ -0,0 +1,66 @@
-module(tk_extractor_claim).
-behaviour(tk_context_extractor).
-include_lib("token_keeper_proto/include/tk_context_thrift.hrl").
-export([get_context/2]).
%% API functions
-spec get_context(tk_token_jwt:t(), tk_context_extractor:extractor_opts()) ->
tk_context_extractor:extracted_context() | undefined.
get_context(Token, _ExtractorOpts) ->
% TODO
% We deliberately do not handle decoding errors here since we extract claims from verified
% tokens only, hence they must be well-formed here.
Claims = tk_token_jwt:get_claims(Token),
case get_claim(Claims) of
{ok, ClaimFragment} ->
{ClaimFragment, undefined};
undefined ->
undefined
end.
%% Internal functions
-define(CLAIM_BOUNCER_CTX, <<"bouncer_ctx">>).
-define(CLAIM_CTX_TYPE, <<"ty">>).
-define(CLAIM_CTX_CONTEXT, <<"ct">>).
-define(CLAIM_CTX_TYPE_V1_THRIFT_BINARY, <<"v1_thrift_binary">>).
-type claim() :: tk_token_jwt:claim().
-type claims() :: tk_token_jwt:claims().
-spec get_claim(claims()) ->
{ok, tk_context_extractor:context_fragment()}
| {error, {unsupported, claim()} | {malformed, binary()}}
| undefined.
get_claim(Claims) ->
case maps:get(?CLAIM_BOUNCER_CTX, Claims, undefined) of
Claim when Claim /= undefined ->
decode_claim(Claim);
undefined ->
undefined
end.
-spec decode_claim(claim()) ->
{ok, tk_context_extractor:context_fragment()} | {error, {unsupported, claim()} | {malformed, binary()}}.
decode_claim(#{
?CLAIM_CTX_TYPE := ?CLAIM_CTX_TYPE_V1_THRIFT_BINARY,
?CLAIM_CTX_CONTEXT := Content
}) ->
try
{ok,
{encoded_context_fragment, #bctx_ContextFragment{
type = v1_thrift_binary,
content = base64:decode(Content)
}}}
catch
% NOTE
% The `base64:decode/1` fails in unpredictable ways.
error:_ ->
{error, {malformed, Content}}
end;
decode_claim(Ctx) ->
{error, {unsupported, Ctx}}.

View File

@ -0,0 +1,39 @@
-module(tk_extractor_detect_token).
-behaviour(tk_context_extractor).
%% Behaviour
-export([get_context/2]).
%% API Types
-type token_source() :: #{
request_origin => binary()
}.
-export_type([token_source/0]).
%% Behaviour
-spec get_context(tk_token_jwt:t(), tk_context_extractor:extractor_opts()) ->
tk_context_extractor:extracted_context() | undefined.
get_context(Token, Opts = #{user_session_token_origins := UserTokenOrigins}) ->
TokenSourceContext = tk_token_jwt:get_source_context(Token),
Opts1 = maps:without([user_session_token_origins], Opts),
tk_context_extractor:get_context(
determine_token_type(TokenSourceContext, UserTokenOrigins),
Token,
Opts1
).
%% Internal functions
determine_token_type(#{request_origin := Origin}, UserTokenOrigins) ->
case lists:member(Origin, UserTokenOrigins) of
true ->
user_session_token;
false ->
phony_api_key
end;
determine_token_type(#{}, _UserTokenOrigins) ->
phony_api_key.

View File

@ -0,0 +1,20 @@
-module(tk_extractor_phony_api_key).
-behaviour(tk_context_extractor).
-export([get_context/2]).
%% API functions
-spec get_context(tk_token_jwt:t(), tk_context_extractor:extractor_opts()) -> tk_context_extractor:extracted_context().
get_context(Token, _ExtractorOpts) ->
UserID = tk_token_jwt:get_subject_id(Token),
Acc0 = bouncer_context_helpers:empty(),
Acc1 = bouncer_context_helpers:add_auth(
#{
method => <<"ApiKeyToken">>,
token => #{id => tk_token_jwt:get_token_id(Token)},
scope => [#{party => #{id => UserID}}]
},
Acc0
),
{Acc1, #{<<"party_id">> => UserID}}.

View File

@ -0,0 +1,41 @@
-module(tk_extractor_user_session_token).
-behaviour(tk_context_extractor).
-export([get_context/2]).
%% API functions
-spec get_context(tk_token_jwt:t(), tk_context_extractor:extractor_opts()) -> tk_context_extractor:extracted_context().
get_context(Token, ExtractorOpts) ->
UserID = tk_token_jwt:get_subject_id(Token),
Email = tk_token_jwt:get_subject_email(Token),
Expiration = tk_token_jwt:get_expires_at(Token),
Acc0 = bouncer_context_helpers:empty(),
Acc1 = bouncer_context_helpers:add_user(
#{
id => UserID,
email => Email,
realm => #{id => maps:get(user_realm, ExtractorOpts, undefined)}
},
Acc0
),
Acc2 = bouncer_context_helpers:add_auth(
#{
method => <<"SessionToken">>,
expiration => make_auth_expiration(Expiration),
token => #{id => tk_token_jwt:get_token_id(Token)}
},
Acc1
),
{Acc2,
genlib_map:compact(#{
<<"user_id">> => UserID,
<<"user_email">> => Email
})}.
%% Internal functions
make_auth_expiration(Timestamp) when is_integer(Timestamp) ->
genlib_rfc3339:format(Timestamp, second);
make_auth_expiration(unlimited) ->
undefined.

View File

@ -35,18 +35,19 @@ do_handle_function('AddExistingToken', _, _State) ->
erlang:error(not_implemented);
do_handle_function('GetByToken' = Op, {Token, TokenSourceContext}, State) ->
_ = handle_beat(Op, started, State),
case tk_token_jwt:verify(Token) of
{ok, TokenInfo} ->
TokenSourceContextDecoded = decode_source_context(TokenSourceContext),
State1 = save_pulse_metadata(#{token => TokenInfo, source => TokenSourceContextDecoded}, State),
case extract_auth_data(TokenInfo, TokenSourceContextDecoded) of
case tk_token_jwt:verify(Token, TokenSourceContextDecoded) of
{ok, TokenInfo} ->
State1 = save_pulse_metadata(#{token => TokenInfo}, State),
Authority = get_token_authority(TokenInfo),
case tk_authority:get_authdata_by_token(TokenInfo, Authority) of
{ok, AuthDataPrototype} ->
EncodedAuthData = encode_auth_data(AuthDataPrototype#{token => Token}),
_ = handle_beat(Op, succeeded, State1),
{ok, EncodedAuthData};
{error, Reason} ->
_ = handle_beat(Op, {failed, {context_creaton, Reason}}, State1),
woody_error:raise(business, #token_keeper_ContextCreationFailed{})
_ = handle_beat(Op, {failed, {not_found, Reason}}, State1),
woody_error:raise(business, #token_keeper_AuthDataNotFound{})
end;
{error, Reason} ->
_ = handle_beat(Op, {failed, {token_verification, Reason}}, State),
@ -66,56 +67,12 @@ make_state(WoodyCtx, Opts) ->
pulse_metadata = #{woody_ctx => WoodyCtx}
}.
extract_auth_data(TokenInfo, TokenSourceContext) ->
TokenType = determine_token_type(TokenSourceContext),
case tk_bouncer_context:extract_context_fragment(TokenInfo, TokenType) of
ContextFragment when ContextFragment =/= undefined ->
AuthDataPrototype = genlib_map:compact(#{
context => ContextFragment,
metadata => extract_token_metadata(TokenType, TokenInfo),
authority => get_authority(TokenInfo)
}),
{ok, AuthDataPrototype};
undefined ->
{error, unable_to_infer_auth_data}
end.
determine_token_type(#{request_origin := Origin}) ->
UserTokenOrigins = application:get_env(token_keeper, user_session_token_origins, []),
case lists:member(Origin, UserTokenOrigins) of
true ->
user_session_token;
false ->
api_key_token
end;
determine_token_type(#{}) ->
api_key_token.
get_authority(TokenInfo) ->
Metadata = tk_token_jwt:get_metadata(TokenInfo),
maps:get(authority, Metadata).
extract_token_metadata(api_key_token, TokenInfo) ->
case tk_token_jwt:get_subject_id(TokenInfo) of
PartyID when PartyID =/= undefined ->
wrap_metadata(#{<<"party_id">> => PartyID}, TokenInfo);
_ ->
undefined
end;
extract_token_metadata(user_session_token, _TokenInfo) ->
undefined.
wrap_metadata(Metadata, TokenInfo) ->
TokenMetadata = tk_token_jwt:get_metadata(TokenInfo),
MetadataNS = maps:get(metadata_ns, TokenMetadata),
#{MetadataNS => Metadata}.
encode_auth_data(AuthData) ->
#token_keeper_AuthData{
id = maps:get(id, AuthData, undefined),
token = maps:get(token, AuthData),
%% Assume active?
status = maps:get(status, AuthData, active),
status = maps:get(status, AuthData),
context = maps:get(context, AuthData),
metadata = maps:get(metadata, AuthData, #{}),
authority = maps:get(authority, AuthData)
@ -128,6 +85,18 @@ decode_source_context(TokenSourceContext) ->
%%
get_token_authority(TokenInfo) ->
AuthorityID = tk_token_jwt:get_authority(TokenInfo),
Authorities = application:get_env(token_keeper, authorities, #{}),
case maps:get(AuthorityID, Authorities, undefined) of
Config when Config =/= undefined ->
Config;
undefined ->
throw({misconfiguration, {no_such_authority, AuthorityID}})
end.
%%
handle_beat(Op, Event, State) ->
tk_pulse:handle_beat({encode_pulse_op(Op), Event}, State#state.pulse_metadata, State#state.pulse).

View File

@ -8,7 +8,6 @@
-type metadata() :: #{
token => tk_token_jwt:t(),
source => token_keeper:token_source(),
woody_ctx => woody_context:ctx()
}.

View File

@ -6,7 +6,7 @@
%% API
-export([issue/3]).
-export([verify/1]).
-export([verify/2]).
-export([get_token_id/1]).
-export([get_subject_id/1]).
@ -15,7 +15,9 @@
-export([get_claims/1]).
-export([get_claim/2]).
-export([get_claim/3]).
-export([get_authority/1]).
-export([get_metadata/1]).
-export([get_source_context/1]).
-export([create_claims/2]).
-export([set_subject_email/2]).
@ -27,7 +29,7 @@
%% API types
-type t() :: {token_id(), claims(), metadata()}.
-type t() :: {token_id(), claims(), authority(), metadata()}.
-type claim() :: expiration() | term().
-type claims() :: #{binary() => claim()}.
-type token() :: binary().
@ -38,14 +40,8 @@
keyset => keyset()
}.
%@TODO Separate classification parameters from jwt decoder logic
-type auth_method() ::
user_session_token | api_key_token | detect.
-type metadata() :: #{
authority := binary(),
metadata_ns => binary(),
auth_method => auth_method(),
user_realm => realm()
source_context => source_context()
}.
-export_type([t/0]).
@ -54,7 +50,6 @@
-export_type([token/0]).
-export_type([expiration/0]).
-export_type([metadata/0]).
-export_type([auth_method/0]).
-export_type([options/0]).
%% Internal types
@ -66,20 +61,23 @@
-type subject_id() :: binary().
-type token_id() :: binary().
-type authority() :: atom().
%??
-type source_context() :: tk_extractor_detect_token:token_source().
-type keyset() :: #{
keyname() => key_opts()
}.
-type key_opts() :: #{
source := keysource(),
metadata => metadata()
authority := authority()
}.
-type keysource() ::
{pem_file, file:filename()}.
-type realm() :: binary().
%%
-define(CLAIM_TOKEN_ID, <<"jti">>).
@ -92,7 +90,7 @@
%%
-spec get_token_id(t()) -> token_id().
get_token_id({TokenId, _Claims, _Metadata}) ->
get_token_id({TokenId, _Claims, _Authority, _Metadata}) ->
TokenId.
-spec get_subject_id(t()) -> subject_id() | undefined.
@ -104,28 +102,36 @@ get_subject_email(T) ->
get_claim(?CLAIM_SUBJECT_EMAIL, T, undefined).
-spec get_expires_at(t()) -> expiration().
get_expires_at({_TokenId, Claims, _Metadata}) ->
get_expires_at({_TokenId, Claims, _Authority, _Metadata}) ->
case maps:get(?CLAIM_EXPIRES_AT, Claims) of
0 -> unlimited;
V -> V
end.
-spec get_claims(t()) -> claims().
get_claims({_TokenId, Claims, _Metadata}) ->
get_claims({_TokenId, Claims, _Authority, _Metadata}) ->
Claims.
-spec get_claim(binary(), t()) -> claim().
get_claim(ClaimName, {_TokenId, Claims, _Metadata}) ->
get_claim(ClaimName, {_TokenId, Claims, _Authority, _Metadata}) ->
maps:get(ClaimName, Claims).
-spec get_claim(binary(), t(), claim()) -> claim().
get_claim(ClaimName, {_TokenId, Claims, _Metadata}, Default) ->
get_claim(ClaimName, {_TokenId, Claims, _Authority, _Metadata}, Default) ->
maps:get(ClaimName, Claims, Default).
-spec get_authority(t()) -> authority().
get_authority({_TokenId, _Claims, Authority, _Metadata}) ->
Authority.
-spec get_metadata(t()) -> metadata().
get_metadata({_TokenId, _Claims, Metadata}) ->
get_metadata({_TokenId, _Claims, _Authority, Metadata}) ->
Metadata.
-spec get_source_context(t()) -> source_context().
get_source_context({_TokenId, _Claims, _Authority, Metadata}) ->
maps:get(source_context, Metadata).
-spec create_claims(claims(), expiration()) -> claims().
create_claims(Claims, Expiration) ->
Claims#{?CLAIM_EXPIRES_AT => Expiration}.
@ -182,7 +188,7 @@ sign(#{kid := KID, jwk := JWK, signer := #{} = JWS}, Claims) ->
%%
-spec verify(token()) ->
-spec verify(token(), source_context()) ->
{ok, t()}
| {error,
{invalid_token,
@ -193,14 +199,14 @@ sign(#{kid := KID, jwk := JWK, signer := #{} = JWS}, Claims) ->
| {invalid_operation, term()}
| invalid_signature}.
verify(Token) ->
verify(Token, SourceContext) ->
try
{_, ExpandedToken} = jose_jws:expand(Token),
#{<<"protected">> := ProtectedHeader} = ExpandedToken,
Header = base64url_to_map(ProtectedHeader),
Alg = get_alg(Header),
KID = get_kid(Header),
verify(KID, Alg, ExpandedToken)
verify(KID, Alg, ExpandedToken, SourceContext)
catch
%% from get_alg and get_kid
throw:Reason ->
@ -214,20 +220,23 @@ base64url_to_map(Base64) when is_binary(Base64) ->
{ok, Json} = jose_base64url:decode(Base64),
jsx:decode(Json, [return_maps]).
verify(KID, Alg, ExpandedToken) ->
verify(KID, Alg, ExpandedToken, SourceContext) ->
case get_key_by_kid(KID) of
#{jwk := JWK, verifier := Algs, metadata := Metadata} ->
#{jwk := JWK, verifier := Algs, authority := Authority} ->
_ = lists:member(Alg, Algs) orelse throw({invalid_operation, Alg}),
verify_with_key(JWK, ExpandedToken, Metadata);
verify_with_key(JWK, ExpandedToken, Authority, make_metadata(SourceContext));
undefined ->
{error, {nonexistent_key, KID}}
end.
verify_with_key(JWK, ExpandedToken, Metadata) ->
make_metadata(SourceContext) ->
#{source_context => SourceContext}.
verify_with_key(JWK, ExpandedToken, Authority, Metadata) ->
case jose_jwt:verify(JWK, ExpandedToken) of
{true, #jose_jwt{fields = Claims}, _JWS} ->
_ = validate_claims(Claims),
get_result(Claims, Metadata);
get_result(Claims, Authority, Metadata);
{false, _JWT, _JWS} ->
{error, invalid_signature}
end.
@ -241,8 +250,8 @@ validate_claims(Claims, [{Name, Claim, Validator} | Rest]) ->
validate_claims(Claims, []) ->
Claims.
get_result(#{?CLAIM_TOKEN_ID := TokenID} = Claims, Metadata) ->
{ok, {TokenID, maps:without([?CLAIM_TOKEN_ID], Claims), Metadata}}.
get_result(#{?CLAIM_TOKEN_ID := TokenID} = Claims, Authority, Metadata) ->
{ok, {TokenID, maps:without([?CLAIM_TOKEN_ID], Claims), Authority, Metadata}}.
get_kid(#{<<"kid">> := KID}) when is_binary(KID) ->
KID;
@ -284,26 +293,13 @@ parse_options(Options) ->
_ = is_map(Keyset) orelse exit({invalid_option, keyset, Keyset}),
_ = genlib_map:foreach(
fun(KeyName, KeyOpts = #{source := Source}) ->
Metadata = maps:get(metadata, KeyOpts),
Authority = maps:get(authority, Metadata),
AuthMethod = maps:get(auth_method, Metadata, undefined),
UserRealm = maps:get(user_realm, Metadata, <<>>),
MetadataNS = maps:get(metadata_ns, Metadata, <<>>),
Authority = maps:get(authority, KeyOpts),
_ =
is_keysource(Source) orelse
exit({invalid_source, KeyName, Source}),
_ =
is_auth_method(AuthMethod) orelse
exit({invalid_auth_method, KeyName, AuthMethod}),
_ =
is_binary(UserRealm) orelse
exit({invalid_user_realm, KeyName, AuthMethod}),
_ =
is_binary(Authority) orelse
exit({invalid_authority, KeyName, AuthMethod}),
_ =
is_binary(MetadataNS) orelse
exit({invalid_metadata_ns, KeyName, MetadataNS})
is_atom(Authority) orelse
exit({invalid_authority, KeyName, Authority})
end,
Keyset
),
@ -314,17 +310,6 @@ is_keysource({pem_file, Fn}) ->
is_keysource(_) ->
false.
is_auth_method(user_session_token) ->
true;
is_auth_method(api_key_token) ->
true;
is_auth_method(detect) ->
true;
is_auth_method(undefined) ->
true;
is_auth_method(_) ->
false.
%%
-spec init(keyset()) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
@ -335,17 +320,17 @@ init(Keyset) ->
ensure_store_key(KeyName, KeyOpts) ->
Source = maps:get(source, KeyOpts),
Metadata = maps:get(metadata, KeyOpts, #{}),
case store_key(KeyName, Source, Metadata) of
Authority = maps:get(authority, KeyOpts),
case store_key(KeyName, Source, Authority) of
ok ->
ok;
{error, Reason} ->
exit({import_error, KeyName, Source, Reason})
end.
-spec store_key(keyname(), {pem_file, file:filename()}, metadata()) -> ok | {error, file:posix() | {unknown_key, _}}.
store_key(Keyname, {pem_file, Filename}, Metadata) ->
store_key(Keyname, {pem_file, Filename}, Metadata, #{
-spec store_key(keyname(), {pem_file, file:filename()}, authority()) -> ok | {error, file:posix() | {unknown_key, _}}.
store_key(Keyname, {pem_file, Filename}, Authority) ->
store_key(Keyname, {pem_file, Filename}, Authority, #{
kid => fun derive_kid_from_public_key_pem_entry/1
}).
@ -359,13 +344,13 @@ derive_kid_from_public_key_pem_entry(JWK) ->
kid => fun((key()) -> kid())
}.
-spec store_key(keyname(), {pem_file, file:filename()}, metadata(), store_opts()) ->
-spec store_key(keyname(), {pem_file, file:filename()}, authority(), store_opts()) ->
ok | {error, file:posix() | {unknown_key, _}}.
store_key(Keyname, {pem_file, Filename}, Metadata, Opts) ->
store_key(Keyname, {pem_file, Filename}, Authority, Opts) ->
case jose_jwk:from_pem_file(Filename) of
JWK = #jose_jwk{} ->
Key = construct_key(derive_kid(JWK, Opts), JWK),
ok = insert_key(Keyname, Key#{metadata => Metadata});
ok = insert_key(Keyname, Key#{authority => Authority});
Error = {error, _} ->
Error
end.

View File

@ -76,12 +76,12 @@ init([]) ->
additional_routes => [erl_health_handle:get_route(Healthcheck)]
}
),
TokensOpts = genlib_app:env(?MODULE, tokens, #{}),
TokensChildSpecs = get_tokens_specs(TokensOpts),
TokensOpts = genlib_app:env(?MODULE, jwt, #{}),
TokensChildSpec = tk_token_jwt:child_spec(TokensOpts),
{ok,
{
#{strategy => one_for_all, intensity => 6, period => 30},
[HandlerChildSpec | TokensChildSpecs]
[HandlerChildSpec, TokensChildSpec]
}}.
-spec get_ip_address() -> inet:ip_address().
@ -133,17 +133,3 @@ enable_health_logging(Check) ->
fun(_, Runner) -> #{runner => Runner, event_handler => EvHandler} end,
Check
).
%%
get_tokens_specs(TokensOpts) ->
maps:fold(
fun(K, V, Acc) ->
[get_token_spec(K, V) | Acc]
end,
[],
TokensOpts
).
get_token_spec(jwt, JWTOptions) ->
tk_token_jwt:child_spec(JWTOptions).

View File

@ -21,7 +21,7 @@
-export([detect_api_key_test/1]).
-export([detect_user_session_token_test/1]).
-export([detect_dummy_token_test/1]).
-export([no_token_metadata_test/1]).
-export([no_token_claim_test/1]).
-export([bouncer_context_from_claims_test/1]).
-type config() :: ct_helper:config().
@ -33,7 +33,11 @@
-define(TK_AUTHORITY_TOKEN_KEEPER, <<"com.rbkmoney.token-keeper">>).
-define(TK_AUTHORITY_KEYCLOAK, <<"com.rbkmoney.keycloak">>).
-define(PARTY_METADATA(Authority, SubjectID), #{Authority := #{<<"party_id">> := SubjectID}}).
-define(METADATA(Authority, Metadata), #{Authority := Metadata}).
-define(PARTY_METADATA(Authority, SubjectID), ?METADATA(Authority, #{<<"party_id">> := SubjectID})).
-define(USER_METADATA(Authority, SubjectID, Email),
?METADATA(Authority, #{<<"user_id">> := SubjectID, <<"user_email">> := Email})
).
-define(TOKEN_SOURCE_CONTEXT(), ?TOKEN_SOURCE_CONTEXT(<<"http://spanish.inquisition">>)).
-define(TOKEN_SOURCE_CONTEXT(SourceURL), #token_keeper_TokenSourceContext{request_origin = SourceURL}).
@ -49,7 +53,7 @@
all() ->
[
{group, detect_token_type},
{group, no_token_metadata}
{group, claim_only}
].
-spec groups() -> [{group_name(), list(), [test_case_name()]}].
@ -60,8 +64,8 @@ groups() ->
detect_user_session_token_test,
detect_dummy_token_test
]},
{no_token_metadata, [parallel], [
no_token_metadata_test,
{claim_only, [parallel], [
no_token_claim_test,
bouncer_context_from_claims_test
]}
].
@ -83,37 +87,52 @@ end_per_suite(C) ->
-spec init_per_group(group_name(), config()) -> config().
init_per_group(detect_token_type = Name, C) ->
start_keeper([
{tokens, #{
jwt => #{
{jwt, #{
keyset => #{
test => #{
source => {pem_file, get_keysource("keys/local/private.pem", C)},
metadata => #{
authority => ?TK_AUTHORITY_KEYCLOAK,
metadata_ns => ?TK_AUTHORITY_TOKEN_KEEPER,
auth_method => detect,
user_realm => <<"external">>
}
}
authority => keycloak
}
}
}},
{user_session_token_origins, [?USER_TOKEN_SOURCE]}
{authorities, #{
keycloak => #{
id => ?TK_AUTHORITY_KEYCLOAK,
authdata_sources => [
storage,
{extract, #{
methods => [
claim,
{detect_token, #{
user_session_token_origins => [?USER_TOKEN_SOURCE],
user_realm => <<"external">>
}}
],
metadata_ns => ?TK_AUTHORITY_TOKEN_KEEPER
}}
]
}
}}
]) ++
[{groupname, Name} | C];
init_per_group(no_token_metadata = Name, C) ->
init_per_group(claim_only = Name, C) ->
start_keeper([
{tokens, #{
jwt => #{
{jwt, #{
keyset => #{
test => #{
source => {pem_file, get_keysource("keys/local/private.pem", C)},
metadata => #{
authority => ?TK_AUTHORITY_KEYCLOAK,
metadata_ns => ?TK_AUTHORITY_TOKEN_KEEPER
}
authority => claim_only
}
}
}},
{authorities, #{
claim_only => #{
id => ?TK_AUTHORITY_KEYCLOAK,
authdata_sources => [
{extract, #{
methods => [claim]
}}
]
}
}}
]) ++
@ -124,7 +143,7 @@ init_per_group(Name, C) ->
-spec end_per_group(group_name(), config()) -> _.
end_per_group(GroupName, C) when
GroupName =:= detect_token_type;
GroupName =:= no_token_metadata
GroupName =:= claim_only
->
ok = stop_keeper(C),
ok;
@ -201,7 +220,10 @@ detect_user_session_token_test(C) ->
AuthData#token_keeper_AuthData.context
)
),
?assertMatch(#{}, AuthData#token_keeper_AuthData.metadata),
?assertMatch(
?USER_METADATA(?TK_AUTHORITY_TOKEN_KEEPER, SubjectID, SubjectEmail),
AuthData#token_keeper_AuthData.metadata
),
?assertEqual(?TK_AUTHORITY_KEYCLOAK, AuthData#token_keeper_AuthData.authority).
-spec detect_dummy_token_test(config()) -> ok.
@ -211,13 +233,13 @@ detect_dummy_token_test(C) ->
#token_keeper_InvalidToken{} =
(catch call_get_by_token(Token, ?TOKEN_SOURCE_CONTEXT(), Client)).
-spec no_token_metadata_test(config()) -> ok.
no_token_metadata_test(C) ->
-spec no_token_claim_test(config()) -> ok.
no_token_claim_test(C) ->
Client = mk_client(C),
JTI = unique_id(),
SubjectID = <<"TEST">>,
{ok, Token} = issue_token(JTI, #{<<"sub">> => SubjectID}, unlimited),
#token_keeper_ContextCreationFailed{} =
#token_keeper_AuthDataNotFound{} =
(catch call_get_by_token(Token, ?TOKEN_SOURCE_CONTEXT(), Client)).
-spec bouncer_context_from_claims_test(config()) -> ok.
@ -230,8 +252,8 @@ bouncer_context_from_claims_test(C) ->
?assertEqual(undefined, AuthData#token_keeper_AuthData.id),
?assertEqual(Token, AuthData#token_keeper_AuthData.token),
?assertEqual(active, AuthData#token_keeper_AuthData.status),
?assert(assert_context({api_key_token, JTI, SubjectID}, AuthData#token_keeper_AuthData.context)),
?assertMatch(?PARTY_METADATA(?TK_AUTHORITY_TOKEN_KEEPER, SubjectID), AuthData#token_keeper_AuthData.metadata),
?assert(assert_context({claim_token, JTI}, AuthData#token_keeper_AuthData.context)),
?assertEqual(#{}, AuthData#token_keeper_AuthData.metadata),
?assertEqual(?TK_AUTHORITY_KEYCLOAK, AuthData#token_keeper_AuthData.authority).
%%
@ -271,6 +293,10 @@ assert_context(TokenInfo, EncodedContextFragment) ->
?assert(assert_user(TokenInfo, User)),
true.
assert_auth({claim_token, JTI}, Auth) ->
?assertEqual(<<"ClaimToken">>, Auth#bctx_v1_Auth.method),
?assertMatch(#bctx_v1_Token{id = JTI}, Auth#bctx_v1_Auth.token),
true;
assert_auth({api_key_token, JTI, SubjectID}, Auth) ->
?assertEqual(<<"ApiKeyToken">>, Auth#bctx_v1_Auth.method),
?assertMatch(#bctx_v1_Token{id = JTI}, Auth#bctx_v1_Auth.token),
@ -282,6 +308,8 @@ assert_auth({user_session_token, JTI, _SubjectID, _SubjectEmail, Exp}, Auth) ->
?assertEqual(make_auth_expiration(Exp), Auth#bctx_v1_Auth.expiration),
true.
assert_user({claim_token, _}, undefined) ->
true;
assert_user({api_key_token, _, _}, undefined) ->
true;
assert_user({user_session_token, _JTI, SubjectID, SubjectEmail, _Exp}, User) ->
@ -307,9 +335,8 @@ issue_token_with_context(JTI, SubjectID) ->
Acc0 = bouncer_context_helpers:empty(),
Acc1 = bouncer_context_helpers:add_auth(
#{
method => <<"ApiKeyToken">>,
token => #{id => JTI},
scope => [#{party => #{id => SubjectID}}]
method => <<"ClaimToken">>,
token => #{id => JTI}
},
Acc0
),

View File

@ -42,17 +42,11 @@ init_per_suite(Config) ->
path => <<"/v1/token-keeper">>
}
}},
{tokens, #{
jwt => #{
{jwt, #{
keyset => #{
test => #{
source => {pem_file, get_keysource("keys/local/private.pem", Config)},
metadata => #{
authority => <<"TEST">>,
auth_method => user_session_token,
user_realm => <<"external">>
}
}
authority => test
}
}
}}
@ -71,12 +65,12 @@ verify_test(_) ->
JTI = unique_id(),
PartyID = <<"TEST">>,
{ok, Token} = issue_token(JTI, #{<<"sub">> => PartyID, <<"TEST">> => <<"TEST">>}, unlimited),
{ok, {JTI, #{<<"sub">> := PartyID, <<"TEST">> := <<"TEST">>}, #{}}} = tk_token_jwt:verify(Token).
{ok, {JTI, #{<<"sub">> := PartyID, <<"TEST">> := <<"TEST">>}, test, #{}}} = tk_token_jwt:verify(Token, #{}).
-spec bad_token_test(config()) -> _.
bad_token_test(Config) ->
{ok, Token} = issue_dummy_token(Config),
{error, invalid_signature} = tk_token_jwt:verify(Token).
{error, invalid_signature} = tk_token_jwt:verify(Token, #{}).
-spec bad_signee_test(config()) -> _.
bad_signee_test(_) ->