ED-175: Ephemeral token issuing (#11)

This commit is contained in:
Alexey 2021-07-30 12:17:08 +03:00 committed by GitHub
parent 81fb238b29
commit 8500ffb9bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 576 additions and 234 deletions

View File

@ -50,23 +50,37 @@
}
}},
%% Token issuing configuration
{issuing, #{
authority => keycloak
}},
%% Authority configuration
{authorities, #{
keycloak => #{
%% What to use as authority id string
id => <<"test.rbkmoney.keycloak">>,
%% Which jwt key to use for signing for this authority
%% You can only use keys that have the same authority configured for them
signer => test,
%% Where to fetch authdata for tokens issued by this authority
authdata_sources => [
%% Fetch from service storage (currently NOT IMPLEMENTED)
storage,
%% Extract bouncer context from the token itself and make up the rest
%% Fetch from storage
{storage,
%% Use token claims as storage
{claim, #{
%% Enable compatability with legacy issued tokens when getting from storage
compatability => {true, <<"test.rbkmoney.apikeymgmt">>}
}}
},
{storage,
%% Use machinegun as storage (NOT IMPLEMENTED)
machinegun
},
%% Create a new bouncer context using token data
{extract, #{
%% Configuration for how to extract said context
methods => [
%% Extract bouncer context from claim
{claim, #{
metadata_ns => <<"test.rbkmoney.apikeymgmt">>
}},
%% Create bouncer context from various legacy data the token has to offer
%% Avaiable options are: `phony_api_key`, `user_session_token`,
%% `invoice_template_access_token`, `detect_token`

View File

@ -39,6 +39,7 @@
{branch, "master"}
}
},
{snowflake, {git, "https://github.com/rbkmoney/snowflake.git", {branch, "master"}}},
{woody,
{git, "https://github.com/rbkmoney/woody_erlang.git",
{branch, "master"}

View File

@ -234,11 +234,14 @@ log_allowed(Level) ->
%%
get_level({get_by_token, started}, _Level) -> log_allowed(debug);
get_level({create_ephemeral, started}, _Level) -> log_allowed(debug);
get_level(_, Level) -> Level.
get_message({get_by_token, started}) -> <<"get_by_token started">>;
get_message({get_by_token, succeeded}) -> <<"get_by_token succeeded">>;
get_message({get_by_token, {failed, _}}) -> <<"get_by_token failed">>.
get_message({get_by_token, {failed, _}}) -> <<"get_by_token failed">>;
get_message({create_ephemeral, started}) -> <<"create_ephemeral started">>;
get_message({create_ephemeral, succeeded}) -> <<"create_ephemeral succeeded">>.
get_beat_metadata({get_by_token, Event}) ->
#{
@ -258,6 +261,20 @@ get_beat_metadata({get_by_token, Event}) ->
error => encode_error(Error)
}
end
};
get_beat_metadata({create_ephemeral, Event}) ->
#{
create_ephemeral =>
case Event of
started ->
#{
event => started
};
succeeded ->
#{
event => succeeded
}
end
}.
encode_error({Class, Details}) when is_atom(Class) ->
@ -278,12 +295,12 @@ extract_opt_meta(K, Metadata, EncodeFun, Acc) ->
error -> Acc
end.
encode_token({JTI, Claims, Authority, TokenMetadata}) ->
encode_token(TokenInfo) ->
#{
jti => JTI,
claims => Claims,
authority => Authority,
metadata => TokenMetadata
jti => tk_token_jwt:get_token_id(TokenInfo),
claims => tk_token_jwt:get_claims(TokenInfo),
authority => tk_token_jwt:get_authority(TokenInfo),
metadata => tk_token_jwt:get_metadata(TokenInfo)
}.
encode_token_source(TokenSourceContext = #{}) ->

View File

@ -2,7 +2,7 @@
%% Behaviour
-callback get_authdata(tk_token_jwt:t(), source_opts()) -> stored_authdata() | undefined.
-callback get_authdata(tk_token_jwt:t(), source_opts()) -> sourced_authdata() | undefined.
%% API functions
@ -11,21 +11,22 @@
%% API Types
-type authdata_source() :: storage_source() | extractor_source().
-type stored_authdata() :: #{
id => tk_authority:id(),
-type sourced_authdata() :: #{
id => tk_authority:authdata_id(),
status := tk_authority:status(),
context := tk_authority:encoded_context_fragment(),
authority => tk_authority:autority_id(),
metadata => tk_authority:metadata()
}.
-export_type([authdata_source/0]).
-export_type([stored_authdata/0]).
-export_type([sourced_authdata/0]).
%% Internal types
-type storage_source() :: maybe_opts(storage, tk_authdata_source_storage:source_opts()).
-type storage_source() :: {storage, tk_authdata_source_storage:source_opts()}.
-type extractor_source() :: maybe_opts(extractor, tk_authdata_source_extractor:source_opts()).
-type maybe_opts(Source, Opts) :: Source | {Source, Opts}.
-type source_opts() ::
@ -34,7 +35,7 @@
%% API functions
-spec get_authdata(authdata_source(), tk_token_jwt:t()) -> stored_authdata() | undefined.
-spec get_authdata(authdata_source(), tk_token_jwt:t()) -> sourced_authdata() | undefined.
get_authdata(AuthDataSource, Token) ->
{Source, Opts} = get_source_opts(AuthDataSource),
Hander = get_source_handler(Source),

View File

@ -9,14 +9,22 @@
%%
-type source_opts() :: #{
methods => tk_context_extractor:methods()
-type extracted_authdata() :: #{
status := tk_authority:status(),
context := tk_authority:encoded_context_fragment(),
metadata => tk_authority:metadata()
}.
-type source_opts() :: #{
methods => tk_extractor:methods()
}.
-export_type([extracted_authdata/0]).
-export_type([source_opts/0]).
%% Behaviour functions
-spec get_authdata(tk_token_jwt:t(), source_opts()) -> tk_authdata_source:stored_authdata() | undefined.
-spec get_authdata(tk_token_jwt:t(), source_opts()) -> extracted_authdata() | undefined.
get_authdata(Token, Opts) ->
Methods = get_extractor_methods(Opts),
case extract_context_with(Methods, Token) of
@ -35,7 +43,7 @@ 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
case tk_extractor:get_context(Method, Token, Opts) of
AuthData when AuthData =/= undefined ->
AuthData;
undefined ->
@ -49,8 +57,6 @@ make_auth_data(ContextFragment, Metadata) ->
metadata => Metadata
}).
encode_context_fragment({encoded_context_fragment, ContextFragment}) ->
ContextFragment;
encode_context_fragment(ContextFragment) ->
#bctx_ContextFragment{
type = v1_thrift_binary,

View File

@ -7,12 +7,26 @@
%%
-type source_opts() :: #{}.
-type stored_authdata() :: tk_storage:storable_authdata().
-type source_opts() :: claim_storage().
-export_type([stored_authdata/0]).
-export_type([source_opts/0]).
%%
-type claim_storage() :: maybe_opts(claim, tk_storage_claim:storage_opts()).
-type maybe_opts(Source, Opts) :: Source | {Source, Opts}.
%% Behaviour functions
-spec get_authdata(tk_token_jwt:t(), source_opts()) -> undefined.
get_authdata(_Token, _Opts) ->
%@TODO: This is for when we actually have storage for authdata
undefined.
-spec get_authdata(tk_token_jwt:t(), source_opts()) -> stored_authdata() | undefined.
get_authdata(Token, StorageOpts) ->
Claims = tk_token_jwt:get_claims(Token),
case tk_storage:get_by_claims(Claims, StorageOpts) of
{ok, AuthData} ->
AuthData;
{error, Reason} ->
_ = logger:warning("Failed storage get: ~p", [Reason]),
undefined
end.

View File

@ -5,34 +5,39 @@
%% API functions
-export([get_id/1]).
-export([get_signer/1]).
-export([create_authdata/4]).
-export([get_authdata_by_token/2]).
%% API Types
-type authority() :: #{
id := autority_id(),
signer => tk_token_jwt:keyname(),
authdata_sources := [tk_authdata_source:authdata_source()]
}.
-type autority_id() :: binary().
-type authdata() :: #{
id => id(),
id => authdata_id(),
status := status(),
context := encoded_context_fragment(),
authority := autority_id(),
metadata => metadata()
}.
-type id() :: binary().
-type authdata_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([authdata_id/0]).
-export_type([status/0]).
-export_type([encoded_context_fragment/0]).
-export_type([metadata/0]).
@ -41,13 +46,30 @@
%% API Functions
-spec get_id(authority()) -> autority_id().
get_id(Authority) ->
maps:get(id, Authority).
-spec get_signer(authority()) -> tk_token_jwt:keyname().
get_signer(Authority) ->
maps:get(signer, Authority).
-spec create_authdata(authdata_id() | undefined, encoded_context_fragment(), metadata(), authority()) -> authdata().
create_authdata(ID, ContextFragment, Metadata, Authority) ->
AuthData = #{
status => active,
context => ContextFragment,
metadata => Metadata
},
add_authority_id(add_id(AuthData, ID), Authority).
-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)};
{ok, maybe_add_authority_id(AuthData, Authority)};
undefined ->
{error, {authdata_not_found, AuthDataSources}}
end.
@ -72,5 +94,15 @@ get_authdata_from_sources([SourceOpts | Rest], Token) ->
get_authdata_from_sources(Rest, Token)
end.
maybe_add_authority_id(AuthData = #{authority := _}, _Authority) ->
AuthData;
maybe_add_authority_id(AuthData, Authority) ->
add_authority_id(AuthData, Authority).
add_id(AuthData, undefined) ->
AuthData;
add_id(AuthData, ID) ->
AuthData#{id => ID}.
add_authority_id(AuthData, Authority) ->
AuthData#{authority => maps:get(id, Authority)}.

View File

@ -1,4 +1,4 @@
-module(tk_context_extractor).
-module(tk_extractor).
%% Behaviour
@ -11,27 +11,21 @@
%% API Types
-type methods() :: [{method(), extractor_opts()} | method()].
-type method() :: claim | detect_token | api_key_token | user_session_token | invoice_template_access_token.
-type method() :: detect_token | api_key_token | user_session_token | invoice_template_access_token.
-type extractor_opts() ::
tk_extractor_claim:extractor_opts()
| tk_extractor_detect_token:extractor_opts()
tk_extractor_detect_token:extractor_opts()
| tk_extractor_phony_api_key:extractor_opts()
| tk_extractor_user_session_token:extractor_opts()
| tk_extractor_invoice_tpl_token:extractor_opts().
-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().
-type 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
@ -43,8 +37,6 @@ get_context(Method, Token, Opts) ->
%%
get_extractor_handler(claim) ->
tk_extractor_claim;
get_extractor_handler(detect_token) ->
tk_extractor_detect_token;
get_extractor_handler(phony_api_key) ->

View File

@ -1,91 +0,0 @@
-module(tk_extractor_claim).
-behaviour(tk_context_extractor).
-include_lib("token_keeper_proto/include/tk_context_thrift.hrl").
-export([get_context/2]).
%%
-type extractor_opts() :: #{
metadata_ns := binary()
}.
-export_type([extractor_opts/0]).
%% API functions
-spec get_context(tk_token_jwt:t(), 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.
case get_bouncer_claim(Token) of
{ok, ClaimFragment} ->
{ClaimFragment, wrap_metadata(get_metadata(Token), ExtractorOpts)};
undefined ->
undefined
end.
%% Internal functions
get_metadata(Token) ->
Metadata = maps:with(get_passthrough_claim_names(), tk_token_jwt:get_claims(Token)),
%% @TEMP: This is a temporary hack.
%% When some external services will stop requiring woody user identity to be present it must be removed too
genlib_map:compact(Metadata#{
<<"party_id">> => tk_token_jwt:get_subject_id(Token)
}).
wrap_metadata(Metadata, _ExtractorOpts) when map_size(Metadata) =:= 0 ->
undefined;
wrap_metadata(Metadata, ExtractorOpts) ->
MetadataNS = maps:get(metadata_ns, ExtractorOpts),
#{MetadataNS => Metadata}.
get_passthrough_claim_names() ->
[
%% token consumer
<<"cons">>
].
-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().
-spec get_bouncer_claim(tk_token_jwt:t()) ->
{ok, tk_context_extractor:context_fragment()}
| {error, {unsupported, claim()} | {malformed, binary()}}
| undefined.
get_bouncer_claim(Token) ->
case tk_token_jwt:get_claim(?CLAIM_BOUNCER_CTX, Token, undefined) of
Claim when Claim /= undefined ->
decode_bouncer_claim(Claim);
undefined ->
undefined
end.
-spec decode_bouncer_claim(claim()) ->
{ok, tk_context_extractor:context_fragment()} | {error, {unsupported, claim()} | {malformed, binary()}}.
decode_bouncer_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_bouncer_claim(Ctx) ->
{error, {unsupported, Ctx}}.

View File

@ -1,5 +1,5 @@
-module(tk_extractor_detect_token).
-behaviour(tk_context_extractor).
-behaviour(tk_extractor).
%% Behaviour
@ -22,11 +22,11 @@
%% Behaviour
-spec get_context(tk_token_jwt:t(), extractor_opts()) -> tk_context_extractor:extracted_context() | undefined.
-spec get_context(tk_token_jwt:t(), extractor_opts()) -> tk_extractor:extracted_context() | undefined.
get_context(Token, Opts = #{user_session_token_origins := UserTokenOrigins}) ->
TokenSourceContext = tk_token_jwt:get_source_context(Token),
TokenType = determine_token_type(TokenSourceContext, UserTokenOrigins),
tk_context_extractor:get_context(TokenType, Token, get_opts(TokenType, Opts)).
tk_extractor:get_context(TokenType, Token, get_opts(TokenType, Opts)).
%% Internal functions

View File

@ -5,7 +5,7 @@
%% therefore a lot of them do not have a standart bouncer context claim built-in.
%% It is advisable to get rid of this exctractor when this issue will be solved.
-behaviour(tk_context_extractor).
-behaviour(tk_extractor).
-export([get_context/2]).
@ -20,7 +20,7 @@
%% API functions
-spec get_context(tk_token_jwt:t(), extractor_opts()) -> tk_context_extractor:extracted_context().
-spec get_context(tk_token_jwt:t(), extractor_opts()) -> tk_extractor:extracted_context().
get_context(Token, ExtractorOpts) ->
UserID = tk_token_jwt:get_subject_id(Token),
case extract_invoice_template_rights(Token, ExtractorOpts) of

View File

@ -1,5 +1,5 @@
-module(tk_extractor_phony_api_key).
-behaviour(tk_context_extractor).
-behaviour(tk_extractor).
-export([get_context/2]).
@ -13,7 +13,7 @@
%% API functions
-spec get_context(tk_token_jwt:t(), extractor_opts()) -> tk_context_extractor:extracted_context().
-spec get_context(tk_token_jwt:t(), extractor_opts()) -> tk_extractor:extracted_context().
get_context(Token, ExtractorOpts) ->
UserID = tk_token_jwt:get_subject_id(Token),
Acc0 = bouncer_context_helpers:empty(),

View File

@ -1,5 +1,5 @@
-module(tk_extractor_user_session_token).
-behaviour(tk_context_extractor).
-behaviour(tk_extractor).
-export([get_context/2]).
@ -14,7 +14,7 @@
%% API functions
-spec get_context(tk_token_jwt:t(), extractor_opts()) -> tk_context_extractor:extracted_context().
-spec get_context(tk_token_jwt:t(), extractor_opts()) -> tk_extractor:extracted_context().
get_context(Token, ExtractorOpts) ->
UserID = tk_token_jwt:get_subject_id(Token),
Email = tk_token_jwt:get_subject_email(Token),
@ -51,7 +51,7 @@ get_context(Token, ExtractorOpts) ->
make_auth_expiration(Timestamp) when is_integer(Timestamp) ->
genlib_rfc3339:format(Timestamp, second);
make_auth_expiration(unlimited) ->
make_auth_expiration(Expiration) when Expiration =:= unlimited; Expiration =:= undefined ->
undefined.
wrap_metadata(Metadata, ExtractorOpts) ->

View File

@ -4,7 +4,10 @@
{get_by_token,
started
| succeeded
| {failed, _Reason}}.
| {failed, _Reason}}
| {create_ephemeral,
started
| succeeded}.
-type metadata() :: #{
token => tk_token_jwt:t(),

70
src/tk_storage.erl Normal file
View File

@ -0,0 +1,70 @@
-module(tk_storage).
-export([get/2]).
-export([get_by_claims/2]).
-export([store/2]).
-export([revoke/2]).
%%
-callback get(authdata_id(), opts()) -> {ok, tk_storage:storable_authdata()} | {error, _Reason}.
-callback get_by_claims(claims(), opts()) -> {ok, tk_storage:storable_authdata()} | {error, _Reason}.
-callback store(tk_storage:storable_authdata(), opts()) -> {ok, claims()} | {error, _Reason}.
-callback revoke(authdata_id(), opts()) -> ok | {error, _Reason}.
%%
-type storable_authdata() :: #{
id => tk_authority:authdata_id(),
status := tk_authority:status(),
context := tk_authority:encoded_context_fragment(),
authority => tk_authority:autority_id(),
metadata => tk_authority:metadata()
}.
-export_type([storable_authdata/0]).
%%
-type authdata_id() :: tk_authority:authdata_id().
-type claims() :: tk_token_jwt:claims().
-type storage_opts() :: {storage(), opts()} | storage().
-type storage() :: claim.
-type opts() :: tk_storage_claim:storage_opts().
%%
-spec get(authdata_id(), storage_opts()) -> {ok, storable_authdata()} | {error, _Reason}.
get(DataID, StorageOpts) ->
{Storage, Opts} = get_storage_opts(StorageOpts),
Handler = get_storage_handler(Storage),
Handler:get(DataID, Opts).
-spec get_by_claims(claims(), storage_opts()) -> {ok, storable_authdata()} | {error, _Reason}.
get_by_claims(Claims, StorageOpts) ->
{Storage, Opts} = get_storage_opts(StorageOpts),
Handler = get_storage_handler(Storage),
Handler:get_by_claims(Claims, Opts).
-spec store(storable_authdata(), storage_opts()) -> {ok, claims()} | {error, _Reason}.
store(AuthData, StorageOpts) ->
{Storage, Opts} = get_storage_opts(StorageOpts),
Handler = get_storage_handler(Storage),
Handler:store(AuthData, Opts).
-spec revoke(authdata_id(), storage_opts()) -> ok | {error, _Reason}.
revoke(DataID, StorageOpts) ->
{Storage, Opts} = get_storage_opts(StorageOpts),
Handler = get_storage_handler(Storage),
Handler:revoke(DataID, Opts).
%%
get_storage_handler(claim) ->
tk_storage_claim.
get_storage_opts({_Storage, _Opts} = StorageOpts) ->
StorageOpts;
get_storage_opts(Storage) when is_atom(Storage) ->
{Storage, #{}}.

133
src/tk_storage_claim.erl Normal file
View File

@ -0,0 +1,133 @@
-module(tk_storage_claim).
-include_lib("token_keeper_proto/include/tk_context_thrift.hrl").
-behaviour(tk_storage).
-export([get/2]).
-export([get_by_claims/2]).
-export([store/2]).
-export([revoke/2]).
-type storage_opts() :: #{
compatability => {true, MetadataNS :: binary()} | false
}.
-export_type([storage_opts/0]).
%%
-type storable_authdata() :: tk_storage:storable_authdata().
-type authdata_id() :: tk_authority:authdata_id().
-type claim() :: tk_token_jwt:claim().
-type claims() :: tk_token_jwt:claims().
-define(CLAIM_BOUNCER_CTX, <<"bouncer_ctx">>).
-define(CLAIM_TK_METADATA, <<"tk_metadata">>).
-define(CLAIM_CTX_TYPE, <<"ty">>).
-define(CLAIM_CTX_CONTEXT, <<"ct">>).
-define(CLAIM_CTX_TYPE_V1_THRIFT_BINARY, <<"v1_thrift_binary">>).
%%
-spec get(authdata_id(), storage_opts()) -> {error, not_found}.
get(_DataID, _Opts) ->
{error, not_found}.
-spec get_by_claims(claims(), storage_opts()) ->
{ok, storable_authdata()}
| {error, not_found | {claim_decode_error, {unsupported, claim()} | {malformed, binary()}}}.
get_by_claims(#{?CLAIM_BOUNCER_CTX := BouncerClaim} = Claims, Opts) ->
case decode_bouncer_claim(BouncerClaim) of
{ok, ContextFragment} ->
case get_metadata(Claims, Opts) of
{ok, Metadata} ->
{ok, create_authdata(ContextFragment, Metadata)};
{error, no_metadata_claim} ->
{error, not_found}
end;
{error, Reason} ->
{error, {claim_decode_error, Reason}}
end;
get_by_claims(_Claims, _Opts) ->
{error, not_found}.
-spec store(storable_authdata(), storage_opts()) -> {ok, claims()}.
store(#{context := ContextFragment} = AuthData, _Opts) ->
{ok, #{
?CLAIM_BOUNCER_CTX => encode_bouncer_claim(ContextFragment),
?CLAIM_TK_METADATA => encode_metadata(AuthData)
}}.
-spec revoke(authdata_id(), storage_opts()) -> {error, storage_immutable}.
revoke(_DataID, _Opts) ->
{error, storage_immutable}.
%% Internal functions
decode_bouncer_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_bouncer_claim(Ctx) ->
{error, {unsupported, Ctx}}.
encode_bouncer_claim(
#bctx_ContextFragment{
type = v1_thrift_binary,
content = Content
}
) ->
#{
?CLAIM_CTX_TYPE => ?CLAIM_CTX_TYPE_V1_THRIFT_BINARY,
?CLAIM_CTX_CONTEXT => base64:encode(Content)
}.
encode_metadata(#{metadata := Metadata}) ->
Metadata;
encode_metadata(#{}) ->
#{}.
get_metadata(#{?CLAIM_TK_METADATA := Metadata}, _Opts) ->
{ok, Metadata};
get_metadata(Claims, #{compatability := {true, MetadataNS}}) ->
{ok, wrap_metadata(create_metadata(Claims), MetadataNS)};
get_metadata(_Claims, _Opts) ->
{error, no_metadata_claim}.
create_authdata(ContextFragment, Metadata) ->
genlib_map:compact(#{
status => active,
context => ContextFragment,
metadata => Metadata
}).
create_metadata(Claims) ->
Metadata = maps:with(get_passthrough_claim_names(), Claims),
%% TODO: This is a temporary hack.
%% When some external services will stop requiring woody user identity to be present it must be removed too
genlib_map:compact(Metadata#{
<<"party_id">> => maps:get(<<"sub">>, Claims, undefined)
}).
wrap_metadata(Metadata, _MetadataNS) when map_size(Metadata) =:= 0 ->
undefined;
wrap_metadata(Metadata, MetadataNS) ->
#{MetadataNS => Metadata}.
get_passthrough_claim_names() ->
[
%% token consumer
<<"cons">>
].

View File

@ -0,0 +1,43 @@
-module(tk_storage_machinegun).
%% NOTE: This storage is not yet implemented
-include_lib("token_keeper_proto/include/tk_context_thrift.hrl").
-behaviour(tk_storage).
-export([get/2]).
-export([get_by_claims/2]).
-export([store/2]).
-export([revoke/2]).
-type storage_opts() :: #{}.
-export_type([storage_opts/0]).
%%
-type storable_authdata() :: tk_storage:storable_authdata().
-type authdata_id() :: tk_authority:authdata_id().
-type claims() :: tk_token_jwt:claims().
%%
-spec get(authdata_id(), storage_opts()) -> {ok, storable_authdata()} | {error, not_found}.
get(_DataID, _Opts) ->
%% Collapse history and return the auth data?
{error, not_found}.
-spec get_by_claims(claims(), storage_opts()) -> {ok, storable_authdata()} | {error, not_found}.
get_by_claims(_Claims, _Opts) ->
%% Extract id from claims, collapse history and return the auth data?
{error, not_found}.
-spec store(storable_authdata(), storage_opts()) -> {ok, claims()} | {error, _Reason}.
store(_AuthData, _Opts) ->
%% Start a new machine, post event, make claims with id
%% Consider ways to generate authdata ids?
{error, not_implemented}.
-spec revoke(authdata_id(), storage_opts()) -> ok | {error, _Reason}.
revoke(_DataID, _Opts) ->
%% Post a revocation event?
{error, not_implemented}.

View File

@ -5,7 +5,7 @@
%% API
-export([issue/3]).
-export([issue/2]).
-export([verify/2]).
-export([get_token_id/1]).
@ -22,6 +22,8 @@
-export([create_claims/2]).
-export([set_subject_email/2]).
-export([get_key_authority/1]).
%% Supervisor callbacks
-export([init/1]).
@ -29,7 +31,7 @@
%% API types
-type t() :: {token_id(), claims(), authority(), metadata()}.
-type t() :: {claims(), authority(), metadata()}.
-type claim() :: expiration() | term().
-type claims() :: #{binary() => claim()}.
-type token() :: binary().
@ -44,6 +46,8 @@
source_context => source_context()
}.
-type keyname() :: term().
-export_type([t/0]).
-export_type([claim/0]).
-export_type([claims/0]).
@ -51,10 +55,10 @@
-export_type([expiration/0]).
-export_type([metadata/0]).
-export_type([options/0]).
-export_type([keyname/0]).
%% Internal types
-type keyname() :: term().
-type kid() :: binary().
-type key() :: #jose_jwk{}.
@ -89,9 +93,9 @@
%% API functions
%%
-spec get_token_id(t()) -> token_id().
get_token_id({TokenId, _Claims, _Authority, _Metadata}) ->
TokenId.
-spec get_token_id(t()) -> token_id() | undefined.
get_token_id(T) ->
get_claim(?CLAIM_TOKEN_ID, T, undefined).
-spec get_subject_id(t()) -> subject_id() | undefined.
get_subject_id(T) ->
@ -101,35 +105,35 @@ get_subject_id(T) ->
get_subject_email(T) ->
get_claim(?CLAIM_SUBJECT_EMAIL, T, undefined).
-spec get_expires_at(t()) -> expiration().
get_expires_at({_TokenId, Claims, _Authority, _Metadata}) ->
case maps:get(?CLAIM_EXPIRES_AT, Claims) of
-spec get_expires_at(t()) -> expiration() | undefined.
get_expires_at(T) ->
case get_claim(?CLAIM_EXPIRES_AT, T, undefined) of
0 -> unlimited;
V -> V
end.
-spec get_claims(t()) -> claims().
get_claims({_TokenId, Claims, _Authority, _Metadata}) ->
get_claims({Claims, _Authority, _Metadata}) ->
Claims.
-spec get_claim(binary(), t()) -> claim().
get_claim(ClaimName, {_TokenId, Claims, _Authority, _Metadata}) ->
get_claim(ClaimName, {Claims, _Authority, _Metadata}) ->
maps:get(ClaimName, Claims).
-spec get_claim(binary(), t(), claim()) -> claim().
get_claim(ClaimName, {_TokenId, Claims, _Authority, _Metadata}, Default) ->
get_claim(ClaimName, {Claims, _Authority, _Metadata}, Default) ->
maps:get(ClaimName, Claims, Default).
-spec get_authority(t()) -> authority().
get_authority({_TokenId, _Claims, Authority, _Metadata}) ->
get_authority({_Claims, Authority, _Metadata}) ->
Authority.
-spec get_metadata(t()) -> metadata().
get_metadata({_TokenId, _Claims, _Authority, Metadata}) ->
get_metadata({_Claims, _Authority, Metadata}) ->
Metadata.
-spec get_source_context(t()) -> source_context().
get_source_context({_TokenId, _Claims, _Authority, Metadata}) ->
get_source_context({_Claims, _Authority, Metadata}) ->
maps:get(source_context, Metadata).
-spec create_claims(claims(), expiration()) -> claims().
@ -143,14 +147,14 @@ set_subject_email(SubjectEmail, Claims) ->
%%
-spec issue(token_id(), claims(), keyname()) ->
-spec issue(claims(), keyname()) ->
{ok, token()}
| {error, nonexistent_key}
| {error, {invalid_signee, Reason :: atom()}}.
issue(JTI, Claims, Signer) ->
issue(Claims, Signer) ->
case try_get_key_for_sign(Signer) of
{ok, Key} ->
FinalClaims = construct_final_claims(Claims, JTI),
FinalClaims = construct_final_claims(Claims),
sign(Key, FinalClaims);
{error, Error} ->
{error, Error}
@ -166,10 +170,8 @@ try_get_key_for_sign(Keyname) ->
{error, nonexistent_key}
end.
construct_final_claims(Claims, JTI) ->
Token0 = #{?CLAIM_TOKEN_ID => JTI},
EncodedClaims = maps:map(fun encode_claim/2, Claims),
maps:merge(EncodedClaims, Token0).
construct_final_claims(Claims) ->
maps:map(fun encode_claim/2, Claims).
encode_claim(?CLAIM_EXPIRES_AT, Expiration) ->
mk_expires_at(Expiration);
@ -235,24 +237,11 @@ make_metadata(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, Authority, Metadata);
{ok, {Claims, Authority, Metadata}};
{false, _JWT, _JWS} ->
{error, invalid_signature}
end.
validate_claims(Claims) ->
validate_claims(Claims, get_validators()).
validate_claims(Claims, [{Name, Claim, Validator} | Rest]) ->
_ = Validator(Name, maps:get(Claim, Claims, undefined)),
validate_claims(Claims, Rest);
validate_claims(Claims, []) ->
Claims.
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;
get_kid(#{}) ->
@ -263,18 +252,16 @@ get_alg(#{<<"alg">> := Alg}) when is_binary(Alg) ->
get_alg(#{}) ->
throw({invalid_token, {missing, alg}}).
get_validators() ->
[
{token_id, ?CLAIM_TOKEN_ID, fun check_presence/2},
{expires_at, ?CLAIM_EXPIRES_AT, fun check_presence/2}
].
%%
check_presence(_, V) when is_binary(V) ->
V;
check_presence(_, V) when is_integer(V) ->
V;
check_presence(C, undefined) ->
throw({invalid_token, {missing, C}}).
-spec get_key_authority(keyname()) -> {ok, authority()} | {error, {nonexistent_key, keyname()}}.
get_key_authority(KeyName) ->
case get_key_by_name(KeyName) of
#{authority := Authority} ->
{ok, Authority};
undefined ->
{error, {nonexistent_key, KeyName}}
end.
%%
%% Supervisor callbacks

View File

@ -1,4 +1,4 @@
-module(tk_handler).
-module(tk_woody_handler).
-include_lib("token_keeper_proto/include/tk_context_thrift.hrl").
-include_lib("token_keeper_proto/include/tk_token_keeper_thrift.hrl").
@ -25,21 +25,27 @@
-spec handle_function(woody:func(), woody:args(), woody_context:ctx(), opts()) -> {ok, woody:result()}.
handle_function(Op, Args, WoodyCtx, Opts) ->
State = make_state(WoodyCtx, Opts),
do_handle_function(Op, Args, State).
handle_function_(Op, Args, State).
do_handle_function('Create', _, _State) ->
handle_function_('Create', {_ID, _ContextFragment, _Metadata}, _State) ->
%% TODO: Change protocol to include authdata id
erlang:error(not_implemented);
do_handle_function('CreateEphemeral', _, _State) ->
handle_function_('CreateEphemeral' = Op, {ContextFragment, Metadata}, State) ->
_ = handle_beat(Op, started, State),
StorageType = claim,
AuthorityID = get_issuing_authority(),
AuthData = issue_token(ContextFragment, Metadata, AuthorityID, StorageType),
_ = handle_beat(Op, succeeded, State),
{ok, AuthData};
handle_function_('AddExistingToken', _, _State) ->
erlang:error(not_implemented);
do_handle_function('AddExistingToken', _, _State) ->
erlang:error(not_implemented);
do_handle_function('GetByToken' = Op, {Token, TokenSourceContext}, State) ->
handle_function_('GetByToken' = Op, {Token, TokenSourceContext}, State) ->
_ = handle_beat(Op, started, State),
TokenSourceContextDecoded = decode_source_context(TokenSourceContext),
case tk_token_jwt:verify(Token, TokenSourceContextDecoded) of
{ok, TokenInfo} ->
State1 = save_pulse_metadata(#{token => TokenInfo}, State),
Authority = get_token_authority(TokenInfo),
Authority = get_autority_config(get_token_authority(TokenInfo)),
case tk_authority:get_authdata_by_token(TokenInfo, Authority) of
{ok, AuthDataPrototype} ->
EncodedAuthData = encode_auth_data(AuthDataPrototype#{token => Token}),
@ -53,13 +59,23 @@ do_handle_function('GetByToken' = Op, {Token, TokenSourceContext}, State) ->
_ = handle_beat(Op, {failed, {token_verification, Reason}}, State),
woody_error:raise(business, #token_keeper_InvalidToken{})
end;
do_handle_function('Get', _, _State) ->
handle_function_('Get', _, _State) ->
erlang:error(not_implemented);
do_handle_function('Revoke', _, _State) ->
handle_function_('Revoke', _, _State) ->
erlang:error(not_implemented).
%% Internal functions
issue_token(ContextFragment, Metadata, AuthorityID, StorageType) ->
issue_token(undefined, ContextFragment, Metadata, AuthorityID, StorageType).
issue_token(ID, ContextFragment, Metadata, AuthorityID, StorageType) ->
Authority = get_autority_config(AuthorityID),
AuthDataPrototype = tk_authority:create_authdata(ID, ContextFragment, Metadata, Authority),
{ok, StorageClaims} = tk_storage:store(AuthDataPrototype, StorageType),
{ok, Token} = tk_token_jwt:issue(StorageClaims, get_signer(AuthorityID, Authority)),
encode_auth_data(AuthDataPrototype#{token => Token}).
make_state(WoodyCtx, Opts) ->
#state{
woody_context = WoodyCtx,
@ -86,7 +102,9 @@ decode_source_context(TokenSourceContext) ->
%%
get_token_authority(TokenInfo) ->
AuthorityID = tk_token_jwt:get_authority(TokenInfo),
tk_token_jwt:get_authority(TokenInfo).
get_autority_config(AuthorityID) ->
Authorities = application:get_env(token_keeper, authorities, #{}),
case maps:get(AuthorityID, Authorities, undefined) of
Config when Config =/= undefined ->
@ -95,6 +113,30 @@ get_token_authority(TokenInfo) ->
throw({misconfiguration, {no_such_authority, AuthorityID}})
end.
get_issuing_authority() ->
maps:get(authority, get_issuing_config()).
get_issuing_config() ->
case application:get_env(token_keeper, issuing, undefined) of
Config when Config =/= undefined ->
Config;
undefined ->
error({misconfiguration, {issuing, not_configured}})
end.
%%
get_signer(AuthorityID, Authority) ->
SignerKID = tk_authority:get_signer(Authority),
case tk_token_jwt:get_key_authority(SignerKID) of
{ok, AuthorityID} ->
SignerKID;
{ok, OtherAuthorityID} ->
error({misconfiguration, {issuing, {key_ownership_error, {AuthorityID, OtherAuthorityID}}}});
_ ->
error({misconfiguration, {issuing, {no_key, SignerKID}}})
end.
%%
handle_beat(Op, Event, State) ->
@ -103,5 +145,7 @@ handle_beat(Op, Event, State) ->
save_pulse_metadata(Metadata, State = #state{pulse_metadata = PulseMetadata}) ->
State#state{pulse_metadata = maps:merge(Metadata, PulseMetadata)}.
encode_pulse_op('CreateEphemeral') ->
create_ephemeral;
encode_pulse_op('GetByToken') ->
get_by_token.

View File

@ -9,6 +9,7 @@
genlib,
woody,
jose,
snowflake,
token_keeper_proto,
bouncer_client,
how_are_you,

View File

@ -110,7 +110,7 @@ get_handler_specs(ServiceOpts, AuditPulse) ->
[
{
maps:get(path, TokenKeeperService, <<"/v1/token-keeper">>),
{{tk_token_keeper_thrift, 'TokenKeeper'}, {tk_handler, TokenKeeperOpts}}
{{tk_token_keeper_thrift, 'TokenKeeper'}, {tk_woody_handler, TokenKeeperOpts}}
}
].

View File

@ -27,6 +27,7 @@
-export([invoice_template_access_token_ok_test/1]).
-export([invoice_template_access_token_no_access_test/1]).
-export([invoice_template_access_token_invalid_access_test/1]).
-export([basic_issuing_test/1]).
-type config() :: ct_helper:config().
-type group_name() :: atom().
@ -63,7 +64,8 @@ all() ->
[
{group, detect_token_type},
{group, claim_only},
{group, invoice_template_access_token}
{group, invoice_template_access_token},
{group, issuing}
].
-spec groups() -> [{group_name(), list(), [test_case_name()]}].
@ -83,6 +85,9 @@ groups() ->
invoice_template_access_token_ok_test,
invoice_template_access_token_no_access_test,
invoice_template_access_token_invalid_access_test
]},
{issuing, [parallel], [
basic_issuing_test
]}
].
@ -115,7 +120,6 @@ init_per_group(detect_token_type = Name, C) ->
keycloak => #{
id => ?TK_AUTHORITY_KEYCLOAK,
authdata_sources => [
storage,
{extract, #{
methods => [
{detect_token, #{
@ -149,13 +153,10 @@ init_per_group(claim_only = Name, C) ->
claim_only => #{
id => ?TK_AUTHORITY_CAPI,
authdata_sources => [
{extract, #{
methods => [
{claim, #{
metadata_ns => ?TK_META_NS_APIKEYMGMT
}}
]
}}
{storage,
{claim, #{
compatability => {true, ?TK_META_NS_APIKEYMGMT}
}}}
]
}
}}
@ -175,11 +176,12 @@ init_per_group(invoice_template_access_token = Name, C) ->
invoice_tpl_authority => #{
id => ?TK_AUTHORITY_CAPI,
authdata_sources => [
{storage,
{claim, #{
compatability => {true, ?TK_META_NS_APIKEYMGMT}
}}},
{extract, #{
methods => [
{claim, #{
metadata_ns => ?TK_META_NS_APIKEYMGMT
}},
{invoice_template_access_token, #{
domain => ?TK_RESOURCE_DOMAIN,
metadata_ns => ?TK_META_NS_APIKEYMGMT
@ -191,6 +193,30 @@ init_per_group(invoice_template_access_token = Name, C) ->
}}
]) ++
[{groupname, Name} | C];
init_per_group(issuing = Name, C) ->
start_keeper([
{jwt, #{
keyset => #{
test => #{
source => {pem_file, get_keysource("keys/local/private.pem", C)},
authority => issuing_authority
}
}
}},
{issuing, #{
authority => issuing_authority
}},
{authorities, #{
issuing_authority => #{
id => ?TK_AUTHORITY_CAPI,
signer => test,
authdata_sources => [
{storage, claim}
]
}
}}
]) ++
[{groupname, Name} | C];
init_per_group(Name, C) ->
[{groupname, Name} | C].
@ -382,6 +408,27 @@ invoice_template_access_token_invalid_access_test(C) ->
#token_keeper_AuthDataNotFound{} =
(catch call_get_by_token(Token, ?TOKEN_SOURCE_CONTEXT(), Client)).
-spec basic_issuing_test(config()) -> ok.
basic_issuing_test(C) ->
Client = mk_client(C),
JTI = unique_id(),
BinaryContextFragment = create_bouncer_context(JTI),
Context = #bctx_ContextFragment{
type = v1_thrift_binary,
content = BinaryContextFragment
},
Metadata = #{<<"ns">> => #{<<"my">> => <<"metadata">>}},
#token_keeper_AuthData{
id = undefined,
token = Token,
status = active,
context = Context,
metadata = Metadata,
authority = ?TK_AUTHORITY_CAPI
} = AuthData = call_create_ephemeral(Context, Metadata, Client),
ok = verify_token(Token, BinaryContextFragment, Metadata, JTI),
AuthData = call_get_by_token(Token, ?TOKEN_SOURCE_CONTEXT(), Client).
%%
mk_client(C) ->
@ -392,6 +439,9 @@ mk_client(C) ->
call_get_by_token(Token, TokenSourceContext, Client) ->
call_token_keeper('GetByToken', {Token, TokenSourceContext}, Client).
call_create_ephemeral(ContextFragment, Metadata, Client) ->
call_token_keeper('CreateEphemeral', {ContextFragment, Metadata}, Client).
call_token_keeper(Operation, Args, Client) ->
call(token_keeper, Operation, Args, Client).
@ -455,6 +505,25 @@ assert_user({user_session_token, _JTI, SubjectID, SubjectEmail, _Exp}, User) ->
%%
verify_token(Token, BinaryContextFragment, Metadata, _JTI) ->
EncodedContextFragment = base64:encode(BinaryContextFragment),
case tk_token_jwt:verify(Token, #{}) of
{ok, TokenInfo} ->
#{
%<<"jti">> := JTI, %% FIXME this will never match
<<"bouncer_ctx">> := #{
<<"ty">> := <<"v1_thrift_binary">>,
<<"ct">> := EncodedContextFragment
},
<<"tk_metadata">> := Metadata
} = tk_token_jwt:get_claims(TokenInfo),
ok;
Error ->
Error
end.
%%
make_auth_expiration(Timestamp) when is_integer(Timestamp) ->
genlib_rfc3339:format(Timestamp, second);
make_auth_expiration(unlimited) ->
@ -462,14 +531,7 @@ make_auth_expiration(unlimited) ->
%%
issue_token(JTI, Claims0, Expiration) ->
Claims = tk_token_jwt:create_claims(Claims0, Expiration),
tk_token_jwt:issue(JTI, Claims, test).
issue_token_with_context(JTI, SubjectID) ->
issue_token_with_context(JTI, SubjectID, #{}).
issue_token_with_context(JTI, SubjectID, AdditionalClaims) ->
create_bouncer_context(JTI) ->
Acc0 = bouncer_context_helpers:empty(),
Acc1 = bouncer_context_helpers:add_auth(
#{
@ -478,7 +540,17 @@ issue_token_with_context(JTI, SubjectID, AdditionalClaims) ->
},
Acc0
),
FragmentContent = encode_context_fragment_content(Acc1),
encode_context_fragment_content(Acc1).
issue_token(JTI, Claims0, Expiration) ->
Claims1 = tk_token_jwt:create_claims(Claims0, Expiration),
tk_token_jwt:issue(Claims1#{<<"jti">> => JTI}, test).
issue_token_with_context(JTI, SubjectID) ->
issue_token_with_context(JTI, SubjectID, #{}).
issue_token_with_context(JTI, SubjectID, AdditionalClaims) ->
FragmentContent = create_bouncer_context(JTI),
issue_token(
JTI,
AdditionalClaims#{

View File

@ -65,7 +65,10 @@ verify_test(_) ->
JTI = unique_id(),
PartyID = <<"TEST">>,
{ok, Token} = issue_token(JTI, #{<<"sub">> => PartyID, <<"TEST">> => <<"TEST">>}, unlimited),
{ok, {JTI, #{<<"sub">> := PartyID, <<"TEST">> := <<"TEST">>}, test, #{}}} = tk_token_jwt:verify(Token, #{}).
{ok, {#{<<"jti">> := JTI, <<"sub">> := PartyID, <<"TEST">> := <<"TEST">>}, test, #{}}} = tk_token_jwt:verify(
Token,
#{}
).
-spec bad_token_test(config()) -> _.
bad_token_test(Config) ->
@ -76,13 +79,13 @@ bad_token_test(Config) ->
bad_signee_test(_) ->
Claims = tk_token_jwt:create_claims(#{}, unlimited),
{error, nonexistent_key} =
tk_token_jwt:issue(unique_id(), Claims, random).
tk_token_jwt:issue(Claims, random).
%%
issue_token(JTI, Claims0, Expiration) ->
Claims = tk_token_jwt:create_claims(Claims0, Expiration),
tk_token_jwt:issue(JTI, Claims, test).
Claims1 = tk_token_jwt:create_claims(Claims0, Expiration),
tk_token_jwt:issue(Claims1#{<<"jti">> => JTI}, test).
issue_dummy_token(Config) ->
Claims = #{