Skip to content

Commit d6eba22

Browse files
fix: Handle failing pubkey refresh (#15)
1 parent 7662fc0 commit d6eba22

8 files changed

+106
-51
lines changed

.github/workflows/erlang.yml

+4-3
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ jobs:
1111
name: OTP ${{matrix.otp}}
1212
strategy:
1313
matrix:
14-
otp: ["23.2", "24.0"]
14+
otp: ["24.0", "25.0"]
1515

1616
steps:
1717
- uses: actions/[email protected]
18-
- uses: gleam-lang/setup-erlang@v1.1.2
18+
- uses: erlef/setup-beam@v1.16.0
1919
with:
20-
otp-version: ${{matrix.otp}}
20+
otp-version: ${{ matrix.otp }}
21+
rebar3-version: '3.16.1'
2122
- name: Compile
2223
run: make
2324
- name: Run xref

elvis.config

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
ignore => [],
99
ruleset => [{elvis_style, state_record_and_type, disable}],
1010
rules => [ {elvis_text_style, line_length,
11-
#{ limit => 80,
11+
#{ limit => 100,
1212
skip_comments => false
1313
}}
1414
, {elvis_text_style, no_tabs}
@@ -57,7 +57,7 @@
5757
],
5858
filter => "*.erl",
5959
rules => [ {elvis_text_style, line_length,
60-
#{ limit => 80,
60+
#{ limit => 100,
6161
skip_comments => false
6262
}}
6363
, {elvis_text_style, no_tabs}

rebar.config

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{erl_opts, [debug_info]}.
2-
{deps, [ {jsx, {git, "https://github.com/talentdeficit/jsx.git" , {tag, "v2.11.0"}}}
3-
, {jose, {git, "https://github.com/potatosalad/erlang-jose.git" , {tag, "1.11.1"}}}
4-
, {hackney, {git, "https://github.com/benoitc/hackney.git" , {tag, "1.17.0"}}}
2+
{deps, [ {jsx, {git, "https://github.com/talentdeficit/jsx.git" , {tag, "v3.1.0"}}}
3+
, {jose, {git, "https://github.com/potatosalad/erlang-jose.git" , {tag, "1.11.5"}}}
4+
, {hackney, {git, "https://github.com/benoitc/hackney.git" , {tag, "1.18.0"}}}
55
]}.
66

77
{shell, [{config, "config/sys.config"},

rebar.lock

+9-12
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,38 @@
11
{"1.2.0",
2-
[{<<"base64url">>,{pkg,<<"base64url">>,<<"0.0.1">>},1},
3-
{<<"certifi">>,{pkg,<<"certifi">>,<<"2.5.3">>},1},
2+
[{<<"certifi">>,{pkg,<<"certifi">>,<<"2.8.0">>},1},
43
{<<"hackney">>,
54
{git,"https://github.com/benoitc/hackney.git",
6-
{ref,"f642ee0eccaff9aa15707ae3a3effa29f2920e41"}},
5+
{ref,"c8f3a6c5a837e4554be219f7c21ae8fdb3d01c40"}},
76
0},
87
{<<"idna">>,{pkg,<<"idna">>,<<"6.1.1">>},1},
98
{<<"jose">>,
109
{git,"https://github.com/potatosalad/erlang-jose.git",
11-
{ref,"090a2ed054304ecc012d6c2d9d10d2a294d835b1"}},
10+
{ref,"e0110a1afacde3011c8bb2a6f1c4ab9ea94bd4f4"}},
1211
0},
1312
{<<"jsx">>,
1413
{git,"https://github.com/talentdeficit/jsx.git",
15-
{ref,"e8d2e01b608e0670a4f82e35ccb5ef3f86115423"}},
14+
{ref,"bb9b3e570a7efe331eed0900c3a5188043a850d7"}},
1615
0},
1716
{<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},1},
1817
{<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.2.0">>},1},
19-
{<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.3.0">>},1},
18+
{<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.3.1">>},1},
2019
{<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.6">>},1},
2120
{<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.0">>},1}]}.
2221
[
2322
{pkg_hash,[
24-
{<<"base64url">>, <<"36A90125F5948E3AFD7BE97662A1504B934DD5DAC78451CA6E9ABF85A10286BE">>},
25-
{<<"certifi">>, <<"70BDD7E7188C804F3A30EE0E7C99655BC35D8AC41C23E12325F36AB449B70651">>},
23+
{<<"certifi">>, <<"D4FB0A6BB20B7C9C3643E22507E42F356AC090A1DCEA9AB99E27E0376D695EBA">>},
2624
{<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>},
2725
{<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>},
2826
{<<"mimerl">>, <<"67E2D3F571088D5CFD3E550C383094B47159F3EEE8FFA08E64106CDF5E981BE3">>},
29-
{<<"parse_trans">>, <<"09765507A3C7590A784615CFD421D101AEC25098D50B89D7AA1D66646BC571C1">>},
27+
{<<"parse_trans">>, <<"16328AB840CC09919BD10DAB29E431DA3AF9E9E7E7E6F0089DD5A2D2820011D8">>},
3028
{<<"ssl_verify_fun">>, <<"CF344F5692C82D2CD7554F5EC8FD961548D4FD09E7D22F5B62482E5AEAEBD4B0">>},
3129
{<<"unicode_util_compat">>, <<"BC84380C9AB48177092F43AC89E4DFA2C6D62B40B8BD132B1059ECC7232F9A78">>}]},
3230
{pkg_hash_ext,[
33-
{<<"base64url">>, <<"FAB09B20E3F5DB886725544CBCF875B8E73EC93363954EB8A1A9ED834AA8C1F9">>},
34-
{<<"certifi">>, <<"ED516ACB3929B101208A9D700062D520F3953DA3B6B918D866106FFA980E1C10">>},
31+
{<<"certifi">>, <<"6AC7EFC1C6F8600B08D625292D4BBF584E14847CE1B6B5C44D983D273E1097EA">>},
3532
{<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>},
3633
{<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>},
3734
{<<"mimerl">>, <<"F278585650AA581986264638EBF698F8BB19DF297F66AD91B18910DFC6E19323">>},
38-
{<<"parse_trans">>, <<"17EF63ABDE837AD30680EA7F857DD9E7CED9476CDD7B0394432AF4BFC241B960">>},
35+
{<<"parse_trans">>, <<"07CD9577885F56362D414E8C4C4E6BDF10D43A8767ABB92D24CBE8B24C54888B">>},
3936
{<<"ssl_verify_fun">>, <<"BDB0D2471F453C88FF3908E7686F86F9BE327D065CC1EC16FA4540197EA04680">>},
4037
{<<"unicode_util_compat">>, <<"25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521">>}]}
4138
].

src/id_token.erl

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ validate(Provider, IdToken) ->
1616
true ->
1717
case id_token_jws:validate(IdToken, Keys) of
1818
{error, no_public_key_matches} ->
19-
refresh_and_validate(Provider, IdToken,
20-
#{force_refresh => true});
19+
Kid = id_token_jws:extract_kid(IdToken),
20+
refresh_and_validate(Provider, IdToken, #{kid => Kid});
2121
Result ->
2222
Result
2323
end;

src/id_token_jws.erl

+7-6
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
-define(API_CALLS,
44
[generate_key_for/1, generate_key_for/2,
55
sign/2, sign/3,
6-
validate/1, validate/2]).
6+
validate/1, validate/2,
7+
extract_kid/1]).
78
-ignore_xref(?API_CALLS).
89
-export(?API_CALLS).
910

@@ -78,6 +79,11 @@ generate_key_for(Alg, Options) ->
7879
{_, PublicKeyMap} = jose_jwk:to_public_map(Jwk),
7980
{Jwk, PublicKeyMap}.
8081

82+
extract_kid(IdToken) ->
83+
Protected = jose_jwt:peek_protected(IdToken),
84+
{_M, #{<<"kid">> := Kid}} = jose_jws:to_map(Protected),
85+
Kid.
86+
8187
%%%=============================================================================
8288
%%% Internal functions
8389
%%%=============================================================================
@@ -120,11 +126,6 @@ kid(_) -> jose_base64url:encode(crypto:strong_rand_bytes(16)).
120126
iat(#{iat := Iat}) -> Iat;
121127
iat(_) -> erlang:system_time(seconds).
122128

123-
extract_kid(IdToken) ->
124-
Protected = jose_jwt:peek_protected(IdToken),
125-
{_M, #{<<"kid">> := Kid}} = jose_jws:to_map(Protected),
126-
Kid.
127-
128129
%%%_* Emacs ====================================================================
129130
%%% Local Variables:
130131
%%% allout-layout: t

src/id_token_provider.erl

+42-15
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
-behaviour(gen_server).
44

5+
-include_lib("kernel/include/logger.hrl").
6+
57
%% API
68
-define(API, [start_link/0, get_cached_keys/1,
79
refresh_keys/1, refresh_keys/2, add_provider/2]).
@@ -14,7 +16,7 @@
1416

1517
-define(REVALIDATE_DELAY, 7).
1618

17-
-type refresh_keys_opts() :: #{force_refresh => boolean()}.
19+
-type refresh_keys_opts() :: #{ kid => binary(), force_refresh => boolean() }.
1820

1921
%%%===================================================================
2022
%%% API
@@ -68,19 +70,29 @@ handle_info({refresh, Provider}, State) ->
6870
#{exp_at := ExpAt} = refresh(Provider),
6971
Now = id_token_util:now_gregorian_seconds(),
7072
RevalidateTime = ExpAt - ?REVALIDATE_DELAY,
71-
case Now < RevalidateTime of
72-
true ->
73-
timer:send_after((RevalidateTime - Now) * 1000,
74-
self(), {refresh, Provider});
75-
false ->
76-
%% Not enough time for revalidation,
77-
%% let the first request pay the price
78-
ok
79-
end,
73+
Delay =
74+
case Now < RevalidateTime of
75+
true ->
76+
(RevalidateTime - Now) * 1000;
77+
false ->
78+
%% Not enough time for revalidation, let the first request pay
79+
%% the price and re-initiate async_revalidate-loop after 60 seconds
80+
60_000
81+
end,
82+
timer:send_after(Delay, self(), {refresh, Provider}),
8083
{noreply, State}.
8184

8285
maybe_refresh(Provider, #{force_refresh := true}) ->
8386
refresh(Provider);
87+
maybe_refresh(Provider, #{kid := Kid}) ->
88+
%% check whether Kid has been added to the cache already while the
89+
%% refresh request was queued in the gen_server inbox
90+
[{Provider, CacheEntry}] = ets:lookup(?ID_TOKEN_CACHE, Provider),
91+
#{keys := Keys} = CacheEntry,
92+
case lists:search(fun(#{<<"kid">> := K}) -> K =:= Kid end, Keys) of
93+
{value, _} -> CacheEntry;
94+
false -> refresh(Provider)
95+
end;
8496
maybe_refresh(Provider, _Opts) ->
8597
[{Provider, CacheEntry}] = ets:lookup(?ID_TOKEN_CACHE, Provider),
8698
#{exp_at := ExpAt, well_known_uri := WellKnownUri} = CacheEntry,
@@ -95,11 +107,19 @@ refresh(Provider) ->
95107
refresh(Provider, WellKnownUri).
96108

97109
refresh(Provider, WellKnownUri) ->
98-
{ok, KeysUrl} = id_token_jwks:get_jwks_uri(WellKnownUri),
99-
{ok, NewKeys} = id_token_jwks:get_pub_keys(KeysUrl),
100-
NewCacheEntry = NewKeys#{well_known_uri => WellKnownUri},
101-
ets:insert(?ID_TOKEN_CACHE, {Provider, NewCacheEntry}),
102-
NewKeys.
110+
case id_token_jwks:get_jwks_uri(WellKnownUri) of
111+
{ok, KeysUrl} ->
112+
case id_token_jwks:get_pub_keys(KeysUrl) of
113+
{ok, NewKeys} ->
114+
NewCacheEntry = NewKeys#{well_known_uri => WellKnownUri},
115+
ets:insert(?ID_TOKEN_CACHE, {Provider, NewCacheEntry}),
116+
NewKeys;
117+
{error, Reason} ->
118+
handle_error("failed to refresh pubkey cache", Provider, Reason)
119+
end;
120+
{error, Reason} ->
121+
handle_error("failed to fetch jwks uri", Provider, Reason)
122+
end.
103123

104124
%%%===================================================================
105125
%%% Internal functions
@@ -112,6 +132,13 @@ add_provider({Name, Uri}) ->
112132
true = ets:insert(?ID_TOKEN_CACHE, EtsEntry),
113133
ok.
114134

135+
handle_error(Message, Provider, Reason) ->
136+
?LOG_ERROR(#{ message => Message,
137+
reason => Reason,
138+
provider => Provider }),
139+
[{Provider, CacheEntry}] = ets:lookup(?ID_TOKEN_CACHE, Provider),
140+
CacheEntry.
141+
115142
%%%_* Emacs ============================================================
116143
%%% Local Variables:
117144
%%% allout-layout: t

test/id_token_SUITE.erl

+37-8
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111
groups() -> [].
1212

13-
all() -> [validate_jwt, keys_are_cached].
13+
all() -> [ validate_jwt,
14+
keys_are_only_refreshed_once_per_kid ].
1415

1516
init_per_suite(Config) ->
1617
application:ensure_all_started(jose),
@@ -22,9 +23,9 @@ init_per_testcase(_TestCase, Config) ->
2223
id_token_jws:generate_key_for(<<"RS256">>, #{key_size => 1024}),
2324
Claims = #{ <<"exp">> => erlang:system_time(second) + 10},
2425
Jwt = id_token_jws:sign(Claims, Jwk),
25-
mock_id_provider(PublicKeyMap),
26+
mock_id_provider(PublicKeyMap, 0),
2627
application:ensure_all_started(id_token),
27-
[{jwt, Jwt} | Config].
28+
[{jwt, Jwt}, {pubkeys, [PublicKeyMap]} | Config].
2829
end_per_testcase(_TestCase, Config) ->
2930
application:stop(id_token),
3031
meck:unload([id_token_jwks, hackney]),
@@ -36,19 +37,47 @@ validate_jwt(Config) ->
3637

3738
keys_are_cached(Config) ->
3839
Jwt = ?config(jwt, Config),
39-
id_token:validate(?ID_PROVIDER, Jwt),
40-
id_token:validate(?ID_PROVIDER, Jwt),
40+
?assertMatch({ok, _}, id_token:validate(?ID_PROVIDER, Jwt)),
41+
?assertMatch({ok, _}, id_token:validate(?ID_PROVIDER, Jwt)),
4142
1 = meck:num_calls(id_token_jwks, get_pub_keys, 1),
4243
ok.
4344

44-
mock_id_provider(PublicKeyMap) ->
45-
meck:expect(id_token_jwks, get_providers, 0,
46-
[{?ID_PROVIDER, ?WELL_KNOWN_URI}]),
45+
keys_are_only_refreshed_once_per_kid(Config) ->
46+
%% init pubkey cache with config from init_per_testcase
47+
CurrentKeyCache = #{ exp_at => id_token_util:now_gregorian_seconds() + 10,
48+
keys => ?config(pubkeys, Config)},
49+
ok = meck:expect(id_token_provider, get_cached_keys, 1, CurrentKeyCache),
50+
51+
%% create JWT with kid that's not yet in the pubkey cache
52+
{Jwk, NewPubkey} = id_token_jws:generate_key_for(<<"RS256">>, #{key_size => 1024}),
53+
Claims = #{ <<"exp">> => erlang:system_time(second) + 10 },
54+
Jwt = id_token_jws:sign(Claims, Jwk),
55+
56+
%% set up provider to return new pubkeys with a 50 ms delay
57+
HttpReponseDelay = 50,
58+
mock_id_provider(NewPubkey, HttpReponseDelay),
59+
?assertEqual(0, meck:num_calls(id_token_jwks, get_pub_keys, 1)),
60+
61+
%% try to validate multiple JWTs based on kid that's not yet in the key cache
62+
spawn(fun() -> id_token:validate(?ID_PROVIDER, Jwt) end),
63+
spawn(fun() -> id_token:validate(?ID_PROVIDER, Jwt) end),
64+
spawn(fun() -> id_token:validate(?ID_PROVIDER, Jwt) end),
65+
timer:sleep(10 * HttpReponseDelay),
66+
67+
%% ensure that the pubkey cache was only refreshed once
68+
?assertEqual(1, meck:num_calls(id_token_jwks, get_pub_keys, 1)),
69+
?assertMatch({ok, _}, id_token:validate(?ID_PROVIDER, Jwt)),
70+
71+
meck:unload([id_token_provider]).
72+
73+
mock_id_provider(PublicKeyMap, HttpReponseDelay) ->
74+
meck:expect(id_token_jwks, get_providers, 0, [{?ID_PROVIDER, ?WELL_KNOWN_URI}]),
4775
meck:expect(hackney, request,
4876
fun(get, ?WELL_KNOWN_URI, _, _, _) ->
4977
Body = jsx:encode(#{<<"jwks_uri">> => ?JWKS_URI}),
5078
{ok, 200, [], Body};
5179
(get, ?JWKS_URI, _, _, _) ->
80+
timer:sleep(HttpReponseDelay),
5281
Body = jsx:encode(#{<<"keys">> => [PublicKeyMap]}),
5382
MaxAge = <<"max-age=3600">>,
5483
Headers = [{<<"Cache-Control">>, MaxAge}],

0 commit comments

Comments
 (0)