From 9a671b6536d419da8ea6c57e647da4312ec072b1 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Sat, 6 May 2017 09:32:33 +0100 Subject: Initial commit --- README.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 000000000..5834629ef --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# jwtf +JSON Web Token Functions -- cgit v1.2.1 From 2c3f9685f0f04b7dc1e1ae6242fa62eca010c9c6 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Sat, 6 May 2017 09:36:34 +0100 Subject: Initial commit Test does not pass yet. --- .gitignore | 4 ++ README.md | 8 +++ src/jwtf.app.src | 30 ++++++++++ src/jwtf.erl | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 .gitignore create mode 100644 src/jwtf.app.src create mode 100644 src/jwtf.erl diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..5eadeac89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*~ +_build/ +doc/ +rebar.lock diff --git a/README.md b/README.md index 5834629ef..84e196ad8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,10 @@ # jwtf + JSON Web Token Functions + +This library provides JWT parsing and validation functions + +Supports; + +# Verify +# RS256 diff --git a/src/jwtf.app.src b/src/jwtf.app.src new file mode 100644 index 000000000..1eec6ef4d --- /dev/null +++ b/src/jwtf.app.src @@ -0,0 +1,30 @@ +% Licensed under the Apache License, Version 2.0 (the "License"); you may not +% use this file except in compliance with the License. You may obtain a copy of +% the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +% License for the specific language governing permissions and limitations under +% the License. + +{application, jwtf, [ + {description, "JSON Web Token Functions"}, + {vsn, git}, + {registered, []}, + {applications, [ + kernel, + stdlib, + b64url, + config, + crypto, + jiffy + ]}, + {env,[]}, + {modules, []}, + {maintainers, []}, + {licenses, []}, + {links, []} +]}. diff --git a/src/jwtf.erl b/src/jwtf.erl new file mode 100644 index 000000000..be930aea8 --- /dev/null +++ b/src/jwtf.erl @@ -0,0 +1,179 @@ +% Licensed under the Apache License, Version 2.0 (the "License"); you may not +% use this file except in compliance with the License. You may obtain a copy of +% the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +% License for the specific language governing permissions and limitations under +% the License. + +-module(jwtf). + +-export([decode/1]). + +-spec decode(EncodedToken :: binary()) -> + {ok, DecodedToken :: term()} | {error, Reason :: term()}. +decode(EncodedToken) -> + try + [Header, Payload, Signature] = split(EncodedToken), + validate(Header, Payload, Signature), + {ok, decode_json(Payload)} + catch + throw:Error -> + Error + end. + + +validate(Header0, Payload0, Signature) -> + Header1 = props(decode_json(Header0)), + validate_header(Header1), + + Payload1 = props(decode_json(Payload0)), + validate_payload(Payload1), + + PublicKey = public_key(Payload1), + rs256_verify(Header0, Payload0, Signature, PublicKey). + + +validate_header(Props) -> + case proplists:get_value(<<"typ">>, Props) of + <<"JWT">> -> + ok; + _ -> + throw({error, invalid_type}) + end, + case proplists:get_value(<<"alg">>, Props) of + <<"RS256">> -> + ok; + _ -> + throw({error, invalid_alg}) + end. + + +validate_payload(Props) -> + validate_iss(Props), + validate_iat(Props), + validate_exp(Props). + + +validate_iss(Props) -> + ExpectedISS = list_to_binary(config:get("iam", "iss")), + case proplists:get_value(<<"iss">>, Props) of + undefined -> + throw({error, missing_iss}); + ExpectedISS -> + ok; + _ -> + throw({error, invalid_iss}) + end. + + +validate_iat(Props) -> + case proplists:get_value(<<"iat">>, Props) of + undefined -> + throw({error, missing_iat}); + IAT -> + assert_past(iat, IAT) + end. + + +validate_exp(Props) -> + case proplists:get_value(<<"exp">>, Props) of + undefined -> + throw({error, missing_exp}); + EXP -> + assert_future(exp, EXP) + end. + + +public_key(Props) -> + KID = case proplists:get_value(<<"kid">>, Props) of + undefined -> + throw({error, missing_kid}); + List -> + binary_to_list(List) + end, + case config:get("iam_rsa_public_keys", KID) of + undefined -> + throw({error, public_key_not_found}); + ExpMod -> + [Exp, Mod] = re:split(ExpMod, ",", [{return, binary}]), + [ + crypto:bytes_to_integer(base64:decode(Exp)), + crypto:bytes_to_integer(base64:decode(Mod)) + ] + end. + + +rs256_verify(Header, Payload, Signature, PublicKey) -> + Message = <
>, + case crypto:verify(rsa, sha256, Message, Signature, PublicKey) of + true -> + ok; + false -> + throw({error, bad_signature}) + end. + + +split(EncodedToken) -> + case binary:split(EncodedToken, <<$.>>, [global]) of + [_, _, _] = Split -> Split; + _ -> throw({error, malformed_token}) + end. + + +decode_json(Encoded) -> + case b64url:decode(Encoded) of + {error, Reason} -> + throw({error, Reason}); + Decoded -> + jiffy:decode(Decoded) + end. + +props({Props}) -> + Props; + +props(_) -> + throw({error, not_object}). + + +assert_past(Name, Time) -> + case Time < now_seconds() of + true -> + ok; + false -> + throw({error, {Name, not_in_past}}) + end. + +assert_future(Name, Time) -> + case Time > now_seconds() of + true -> + ok; + false -> + throw({error, {Name, not_in_future}}) + end. + + +now_seconds() -> + {MegaSecs, Secs, _MicroSecs} = os:timestamp(), + MegaSecs * 1000000 + Secs. + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +decode_test() -> + ok = application:start(config), + + EncodedToken = <<"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2Zvby5jb20iLCJpYXQiOjAsImV4cCI6MTAwMDAwMDAwMDAwMDAsImtpZCI6ImJhciJ9.bi87-lkEeOblTb_5ZEh6FkmOSg3mC_kqu2xcYJpJb3So29agyJkkidu3NF8R20x-Xi1wD6E8ACgfODsbdu5dbNRc-HUaFUnvyBr-M94PXhSOvLduoXT2mg1tgD1s_n0QgmH0pP-aAINgotDiUBuQ-pMD5hDIX2EYqAjwRcnVrno">>, + + PublicKey = "AQAB,3ZWrUY0Y6IKN1qI4BhxR2C7oHVFgGPYkd38uGq1jQNSqEvJFcN93CYm16/G78FAFKWqwsJb3Wx+nbxDn6LtP4AhULB1H0K0g7/jLklDAHvI8yhOKlvoyvsUFPWtNxlJyh5JJXvkNKV/4Oo12e69f8QCuQ6NpEPl+cSvXIqUYBCs=", + + config:set("iam", "iss", "https://foo.com"), + config:set("iam_rsa_public_keys", "bar", PublicKey), + + ?assertEqual(nope, decode(EncodedToken)). + +-endif. -- cgit v1.2.1 From f2e1085805ef81a649233965c378eed12faad653 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Sat, 6 May 2017 09:52:13 +0100 Subject: validate nbf --- src/jwtf.erl | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/jwtf.erl b/src/jwtf.erl index be930aea8..566dd0e92 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -56,6 +56,7 @@ validate_header(Props) -> validate_payload(Props) -> validate_iss(Props), validate_iat(Props), + validate_nbf(Props), validate_exp(Props). @@ -80,6 +81,15 @@ validate_iat(Props) -> end. +validate_nbf(Props) -> + case proplists:get_value(<<"nbf">>, Props) of + undefined -> + throw({error, missing_nbf}); + IAT -> + assert_past(iat, IAT) + end. + + validate_exp(Props) -> case proplists:get_value(<<"exp">>, Props) of undefined -> -- cgit v1.2.1 From 3888d182a474fcc65d749a950e5f8f38648073dd Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Sat, 6 May 2017 12:03:07 +0100 Subject: Moar Functional * remove dependency on config * make checks optional * support HS256 --- src/jwtf.app.src | 1 - src/jwtf.erl | 169 ++++++++++++++++++++++++++++++++++++------------------- 2 files changed, 112 insertions(+), 58 deletions(-) diff --git a/src/jwtf.app.src b/src/jwtf.app.src index 1eec6ef4d..d210f4c43 100644 --- a/src/jwtf.app.src +++ b/src/jwtf.app.src @@ -18,7 +18,6 @@ kernel, stdlib, b64url, - config, crypto, jiffy ]}, diff --git a/src/jwtf.erl b/src/jwtf.erl index 566dd0e92..61f141d82 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -12,14 +12,12 @@ -module(jwtf). --export([decode/1]). +-export([decode/3]). --spec decode(EncodedToken :: binary()) -> - {ok, DecodedToken :: term()} | {error, Reason :: term()}. -decode(EncodedToken) -> +decode(EncodedToken, Checks, KS) -> try [Header, Payload, Signature] = split(EncodedToken), - validate(Header, Payload, Signature), + validate(Header, Payload, Signature, Checks, KS), {ok, decode_json(Payload)} catch throw:Error -> @@ -27,99 +25,125 @@ decode(EncodedToken) -> end. -validate(Header0, Payload0, Signature) -> +validate(Header0, Payload0, Signature, Checks, KS) -> Header1 = props(decode_json(Header0)), validate_header(Header1), Payload1 = props(decode_json(Payload0)), - validate_payload(Payload1), + validate_payload(Payload1, Checks), - PublicKey = public_key(Payload1), - rs256_verify(Header0, Payload0, Signature, PublicKey). + Alg = prop(<<"alg">>, Header1), + Key = key(Payload1, Checks, KS), + verify(Alg, Header0, Payload0, Signature, Key). validate_header(Props) -> - case proplists:get_value(<<"typ">>, Props) of + case prop(<<"typ">>, Props) of <<"JWT">> -> ok; _ -> throw({error, invalid_type}) end, - case proplists:get_value(<<"alg">>, Props) of + case prop(<<"alg">>, Props) of <<"RS256">> -> ok; + <<"HS256">> -> + ok; _ -> throw({error, invalid_alg}) end. -validate_payload(Props) -> - validate_iss(Props), - validate_iat(Props), - validate_nbf(Props), - validate_exp(Props). +%% Not all these fields have to be present, but if they _are_ present +%% they must be valid. +validate_payload(Props, Checks) -> + validate_iss(Props, Checks), + validate_iat(Props, Checks), + validate_nbf(Props, Checks), + validate_exp(Props, Checks). + +validate_iss(Props, Checks) -> + ExpectedISS = prop(iss, Checks), + ActualISS = prop(<<"iss">>, Props), -validate_iss(Props) -> - ExpectedISS = list_to_binary(config:get("iam", "iss")), - case proplists:get_value(<<"iss">>, Props) of - undefined -> + case {ExpectedISS, ActualISS} of + {ISS, undefined} when ISS /= undefined -> throw({error, missing_iss}); - ExpectedISS -> + {ISS, ISS} -> ok; - _ -> + {_, _} -> throw({error, invalid_iss}) end. -validate_iat(Props) -> - case proplists:get_value(<<"iat">>, Props) of - undefined -> +validate_iat(Props, Checks) -> + Required = prop(iat, Checks), + IAT = prop(<<"iat">>, Props), + + case {Required, IAT} of + {undefined, undefined} -> + ok; + {true, undefined} -> throw({error, missing_iat}); - IAT -> + {true, IAT} -> assert_past(iat, IAT) end. -validate_nbf(Props) -> - case proplists:get_value(<<"nbf">>, Props) of - undefined -> +validate_nbf(Props, Checks) -> + Required = prop(nbf, Checks), + NBF = prop(<<"nbf">>, Props), + + case {Required, NBF} of + {undefined, undefined} -> + ok; + {true, undefined} -> throw({error, missing_nbf}); - IAT -> + {true, IAT} -> assert_past(iat, IAT) end. -validate_exp(Props) -> - case proplists:get_value(<<"exp">>, Props) of - undefined -> +validate_exp(Props, Checks) -> + Required = prop(exp, Checks), + EXP = prop(<<"exp">>, Props), + + case {Required, EXP} of + {undefined, undefined} -> + ok; + {true, undefined} -> throw({error, missing_exp}); - EXP -> + {true, EXP} -> assert_future(exp, EXP) end. -public_key(Props) -> - KID = case proplists:get_value(<<"kid">>, Props) of - undefined -> +key(Props, Checks, KS) -> + Required = prop(kid, Checks), + KID = prop(<<"kid">>, Props), + case {Required, KID} of + {undefined, undefined} -> + KS(undefined); + {true, undefined} -> throw({error, missing_kid}); - List -> - binary_to_list(List) - end, - case config:get("iam_rsa_public_keys", KID) of - undefined -> - throw({error, public_key_not_found}); - ExpMod -> - [Exp, Mod] = re:split(ExpMod, ",", [{return, binary}]), - [ - crypto:bytes_to_integer(base64:decode(Exp)), - crypto:bytes_to_integer(base64:decode(Mod)) - ] + {true, KID} -> + KS(KID) end. -rs256_verify(Header, Payload, Signature, PublicKey) -> +verify(Alg, Header, Payload, Signature0, Key) -> Message = <
>, + Signature1 = b64url:decode(Signature0), + case Alg of + <<"RS256">> -> + rs256_verify(Message, Signature1, Key); + <<"HS256">> -> + hs256_verify(Message, Signature1, Key) + end. + + +rs256_verify(Message, Signature, PublicKey) -> case crypto:verify(rsa, sha256, Message, Signature, PublicKey) of true -> ok; @@ -128,6 +152,15 @@ rs256_verify(Header, Payload, Signature, PublicKey) -> end. +hs256_verify(Message, HMAC, SecretKey) -> + case crypto:hmac(sha256, SecretKey, Message) of + HMAC -> + ok; + E -> + throw({error, bad_hmac}) + end. + + split(EncodedToken) -> case binary:split(EncodedToken, <<$.>>, [global]) of [_, _, _] = Split -> Split; @@ -171,19 +204,41 @@ now_seconds() -> {MegaSecs, Secs, _MicroSecs} = os:timestamp(), MegaSecs * 1000000 + Secs. + +prop(Prop, Props) -> + proplists:get_value(Prop, Props). + + -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -decode_test() -> - ok = application:start(config), +hs256_test() -> + EncodedToken = <<"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwc" + "zovL2Zvby5jb20iLCJpYXQiOjAsImV4cCI6MTAwMDAwMDAwMDAwMDA" + "sImtpZCI6ImJhciJ9.lpOvEnYLdcujwo9RbhzXme6J-eQ1yfl782qq" + "crR6QYE">>, + KS = fun(_) -> <<"secret">> end, + Checks = [{iss, <<"https://foo.com">>}, iat, exp, kid], + ?assertMatch({ok, _}, decode(EncodedToken, Checks, KS)). - EncodedToken = <<"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2Zvby5jb20iLCJpYXQiOjAsImV4cCI6MTAwMDAwMDAwMDAwMDAsImtpZCI6ImJhciJ9.bi87-lkEeOblTb_5ZEh6FkmOSg3mC_kqu2xcYJpJb3So29agyJkkidu3NF8R20x-Xi1wD6E8ACgfODsbdu5dbNRc-HUaFUnvyBr-M94PXhSOvLduoXT2mg1tgD1s_n0QgmH0pP-aAINgotDiUBuQ-pMD5hDIX2EYqAjwRcnVrno">>, +rs256_test() -> + EncodedToken = <<"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwc" + "zovL2Zvby5jb20iLCJpYXQiOjAsImV4cCI6MTAwMDAwMDAwMDAwMDA" + "sImtpZCI6ImJhciJ9.bi87-lkEeOblTb_5ZEh6FkmOSg3mC_kqu2xc" + "YJpJb3So29agyJkkidu3NF8R20x-Xi1wD6E8ACgfODsbdu5dbNRc-H" + "UaFUnvyBr-M94PXhSOvLduoXT2mg1tgD1s_n0QgmH0pP-aAINgotDi" + "UBuQ-pMD5hDIX2EYqAjwRcnVrno">>, - PublicKey = "AQAB,3ZWrUY0Y6IKN1qI4BhxR2C7oHVFgGPYkd38uGq1jQNSqEvJFcN93CYm16/G78FAFKWqwsJb3Wx+nbxDn6LtP4AhULB1H0K0g7/jLklDAHvI8yhOKlvoyvsUFPWtNxlJyh5JJXvkNKV/4Oo12e69f8QCuQ6NpEPl+cSvXIqUYBCs=", + PublicKey = <<"AQAB,3ZWrUY0Y6IKN1qI4BhxR2C7oHVFgGPYkd38uGq1jQNSqEvJFcN93CY" + "m16/G78FAFKWqwsJb3Wx+nbxDn6LtP4AhULB1H0K0g7/jLklDAHvI8yhOKl" + "voyvsUFPWtNxlJyh5JJXvkNKV/4Oo12e69f8QCuQ6NpEPl+cSvXIqUYBCs=">>, - config:set("iam", "iss", "https://foo.com"), - config:set("iam_rsa_public_keys", "bar", PublicKey), + Checks = [{iss, <<"https://foo.com">>}, iat, exp, kid], + KS = fun(<<"bar">>) -> PublicKey end, - ?assertEqual(nope, decode(EncodedToken)). + ?assertMatch({ok, _}, decode(EncodedToken, Checks, KS)). -endif. + + + -- cgit v1.2.1 From 5f93661ba16a48a521c70d621392cda8ad385548 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Sat, 6 May 2017 12:26:57 +0100 Subject: unused var --- src/jwtf.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index 61f141d82..d7f9bdee9 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -156,7 +156,7 @@ hs256_verify(Message, HMAC, SecretKey) -> case crypto:hmac(sha256, SecretKey, Message) of HMAC -> ok; - E -> + _ -> throw({error, bad_hmac}) end. -- cgit v1.2.1 From 02ecf5b76321f6fc4a4b218543da0752df5f798a Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Sun, 7 May 2017 20:09:51 +0100 Subject: add more tests --- src/jwtf.app.src | 3 +- src/jwtf.erl | 132 +++++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 115 insertions(+), 20 deletions(-) diff --git a/src/jwtf.app.src b/src/jwtf.app.src index d210f4c43..304bb9e0a 100644 --- a/src/jwtf.app.src +++ b/src/jwtf.app.src @@ -19,7 +19,8 @@ stdlib, b64url, crypto, - jiffy + jiffy, + public_key ]}, {env,[]}, {modules, []}, diff --git a/src/jwtf.erl b/src/jwtf.erl index d7f9bdee9..e63a25823 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -42,7 +42,7 @@ validate_header(Props) -> <<"JWT">> -> ok; _ -> - throw({error, invalid_type}) + throw({error, invalid_typ}) end, case prop(<<"alg">>, Props) of <<"RS256">> -> @@ -101,7 +101,7 @@ validate_nbf(Props, Checks) -> {true, undefined} -> throw({error, missing_nbf}); {true, IAT} -> - assert_past(iat, IAT) + assert_past(nbf, IAT) end. @@ -144,7 +144,7 @@ verify(Alg, Header, Payload, Signature0, Key) -> rs256_verify(Message, Signature, PublicKey) -> - case crypto:verify(rsa, sha256, Message, Signature, PublicKey) of + case public_key:verify(Message, sha256, Signature, PublicKey) of true -> ok; false -> @@ -212,31 +212,125 @@ prop(Prop, Props) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). +encode(Header0, Payload0) -> + Header1 = b64url:encode(jiffy:encode(Header0)), + Payload1 = b64url:encode(jiffy:encode(Payload0)), + Sig = b64url:encode(<<"bad">>), + <>. + +valid_header() -> + {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"RS256">>}]}. + +jwt_io_pubkey() -> + PublicKeyPEM = <<"-----BEGIN PUBLIC KEY-----\n" + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGH" + "FHYLugdUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6" + "dvEOfou0/gCFQsHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkl" + "e+Q0pX/g6jXZ7r1/xAK5Do2kQ+X5xK9cipRgEKwIDAQAB\n" + "-----END PUBLIC KEY-----\n">>, + [PEMEntry] = public_key:pem_decode(PublicKeyPEM), + public_key:pem_entry_decode(PEMEntry). + + +invalid_typ_test() -> + Encoded = encode({[{<<"typ">>, <<"NOPE">>}]}, []), + ?assertEqual({error, invalid_typ}, decode(Encoded, [typ], nil)). + + +invalid_alg_test() -> + Encoded = encode({[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"NOPE">>}]}, []), + ?assertEqual({error, invalid_alg}, decode(Encoded, [alg], nil)). + + +missing_iss_test() -> + Encoded = encode(valid_header(), {[]}), + ?assertEqual({error, missing_iss}, decode(Encoded, [{iss, right}], nil)). + + +invalid_iss_test() -> + Encoded = encode(valid_header(), {[{<<"iss">>, <<"wrong">>}]}), + ?assertEqual({error, invalid_iss}, decode(Encoded, [{iss, right}], nil)). + + +missing_iat_test() -> + Encoded = encode(valid_header(), {[]}), + ?assertEqual({error, missing_iat}, decode(Encoded, [iat], nil)). + + +invalid_iat_test() -> + Encoded = encode(valid_header(), {[{<<"iat">>, 32503680000}]}), + ?assertEqual({error, {iat,not_in_past}}, decode(Encoded, [iat], nil)). + + +missing_nbf_test() -> + Encoded = encode(valid_header(), {[]}), + ?assertEqual({error, missing_nbf}, decode(Encoded, [nbf], nil)). + + +invalid_nbf_test() -> + Encoded = encode(valid_header(), {[{<<"nbf">>, 32503680000}]}), + ?assertEqual({error, {nbf,not_in_past}}, decode(Encoded, [nbf], nil)). + + +missing_exp_test() -> + Encoded = encode(valid_header(), {[]}), + ?assertEqual({error, missing_exp}, decode(Encoded, [exp], nil)). + + +invalid_exp_test() -> + Encoded = encode(valid_header(), {[{<<"exp">>, 0}]}), + ?assertEqual({error, {exp,not_in_future}}, decode(Encoded, [exp], nil)). + + +bad_rs256_sig_test() -> + Encoded = encode( + {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"RS256">>}]}, + {[]}), + KS = fun(undefined) -> jwt_io_pubkey() end, + ?assertEqual({error, bad_signature}, decode(Encoded, [], KS)). + + +bad_hs256_sig_test() -> + Encoded = encode( + {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"HS256">>}]}, + {[]}), + KS = fun(undefined) -> <<"bad">> end, + ?assertEqual({error, bad_hmac}, decode(Encoded, [], KS)). + + +malformed_token_test() -> + ?assertEqual({error, malformed_token}, decode(<<"a.b.c.d">>, [], nil)). + + hs256_test() -> EncodedToken = <<"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwc" "zovL2Zvby5jb20iLCJpYXQiOjAsImV4cCI6MTAwMDAwMDAwMDAwMDA" "sImtpZCI6ImJhciJ9.lpOvEnYLdcujwo9RbhzXme6J-eQ1yfl782qq" "crR6QYE">>, KS = fun(_) -> <<"secret">> end, - Checks = [{iss, <<"https://foo.com">>}, iat, exp, kid], + Checks = [{iss, <<"https://foo.com">>}, iat, exp, kid, sig], ?assertMatch({ok, _}, decode(EncodedToken, Checks, KS)). -rs256_test() -> - EncodedToken = <<"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwc" - "zovL2Zvby5jb20iLCJpYXQiOjAsImV4cCI6MTAwMDAwMDAwMDAwMDA" - "sImtpZCI6ImJhciJ9.bi87-lkEeOblTb_5ZEh6FkmOSg3mC_kqu2xc" - "YJpJb3So29agyJkkidu3NF8R20x-Xi1wD6E8ACgfODsbdu5dbNRc-H" - "UaFUnvyBr-M94PXhSOvLduoXT2mg1tgD1s_n0QgmH0pP-aAINgotDi" - "UBuQ-pMD5hDIX2EYqAjwRcnVrno">>, - PublicKey = <<"AQAB,3ZWrUY0Y6IKN1qI4BhxR2C7oHVFgGPYkd38uGq1jQNSqEvJFcN93CY" - "m16/G78FAFKWqwsJb3Wx+nbxDn6LtP4AhULB1H0K0g7/jLklDAHvI8yhOKl" - "voyvsUFPWtNxlJyh5JJXvkNKV/4Oo12e69f8QCuQ6NpEPl+cSvXIqUYBCs=">>, - - Checks = [{iss, <<"https://foo.com">>}, iat, exp, kid], - KS = fun(<<"bar">>) -> PublicKey end, - - ?assertMatch({ok, _}, decode(EncodedToken, Checks, KS)). +%% jwt.io example +rs256_test() -> + EncodedToken = <<"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0N" + "TY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.Ek" + "N-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8j" + "O19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHGuERTqYZyuhtF" + "39yxJPAjUESwxk2J5k_4zM3O-vtd1Ghyo4IbqKKSy6J9mTniYJPenn" + "5-HIirE">>, + + Checks = [sig], + KS = fun(undefined) -> jwt_io_pubkey() end, + + ExpectedPayload = {[ + {<<"sub">>, <<"1234567890">>}, + {<<"name">>, <<"John Doe">>}, + {<<"admin">>, true} + ]}, + + ?assertMatch({ok, ExpectedPayload}, decode(EncodedToken, Checks, KS)). -endif. -- cgit v1.2.1 From 5b9dad72f40750abb52184d925a15667b29abe1e Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Mon, 8 May 2017 15:45:53 +0100 Subject: Add JKWS cache --- src/jwks.erl | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/jwtf.erl | 3 -- 2 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 src/jwks.erl diff --git a/src/jwks.erl b/src/jwks.erl new file mode 100644 index 000000000..62bf3ca1d --- /dev/null +++ b/src/jwks.erl @@ -0,0 +1,141 @@ +% Licensed under the Apache License, Version 2.0 (the "License"); you may not +% use this file except in compliance with the License. You may obtain a copy of +% the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +% License for the specific language governing permissions and limitations under +% the License. + +-module(jwks). +-behaviour(gen_server). + +-export([ + start_link/1, + get_key/2 +]). + +-export([ + init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + code_change/3, + terminate/2 +]). + +start_link(Url) -> + gen_server:start_link({local, ?MODULE}, ?MODULE, Url, []). + + +get_key(Pid, Kid) -> + case lookup(Kid) of + {ok, Key} -> + %% couch_stats:increment_counter([jkws, hit]), + {ok, Key}; + {error, not_found} -> + %% couch_stats:increment_counter([jkws, miss]), + Url = gen_server:call(Pid, get_url), + KeySet = get_keyset(Url), + ok = gen_server:call(Pid, {replace_keyset, KeySet}), + lookup(Kid) + end. + + +lookup(Kid) -> + case ets:lookup(?MODULE, Kid) of + [{Kid, Key}] -> + {ok, Key}; + [] -> + {error, not_found} + end. + + + +%% gen_server functions + +init(Url) -> + ?MODULE = ets:new(?MODULE, [protected, named_table, {read_concurrency, true}]), + KeySet = get_keyset(Url), + set_keyset(KeySet), + {ok, Url}. + + +handle_call({replace_keyset, KeySet}, _From, State) -> + set_keyset(KeySet), + {reply, ok, State}; + +handle_call(get_url, _From, State) -> + {reply, State, State}; + +handle_call(_Msg, _From, State) -> + {noreply, State}. + + +handle_cast(_Msg, State) -> + {noreply, State}. + + +handle_info(_Msg, State) -> + {noreply, State}. + + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + + +terminate(_Reason, _State) -> + ok. + +%% private functions + +get_keyset(Url) -> + ReqHeaders = [], + %% T0 = os:timestamp(), + case ibrowse:send_req(Url, ReqHeaders, get) of + {ok, "200", _RespHeaders, RespBody} -> + %% Latency = timer:now_diff(os:timestamp(), T0) / 1000, + %% couch_stats:update_histogram([jkws, latency], Latency), + parse_keyset(RespBody); + Else -> + io:format("~p", [Else]), + [] + end. + + +set_keyset(KeySet) -> + true = ets:delete_all_objects(?MODULE), + true = ets:insert(?MODULE, KeySet). + + +parse_keyset(Body) -> + {Props} = jiffy:decode(Body), + Keys = proplists:get_value(<<"keys">>, Props), + [parse_key(Key) || Key <- Keys]. + + +parse_key({Props}) -> + <<"RS256">> = proplists:get_value(<<"alg">>, Props), + <<"RSA">> = proplists:get_value(<<"kty">>, Props), + Kid = proplists:get_value(<<"kid">>, Props), + E = proplists:get_value(<<"e">>, Props), + N = proplists:get_value(<<"n">>, Props), + {Kid, {'RSAPublicKey', decode_number(N), decode_number(E)}}. + + +decode_number(Base64) -> + crypto:bytes_to_integer(b64url:decode(Base64)). + + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +jwks_test() -> + application:start(ibrowse), + jwks:start_link("https://iam.stage1.eu-gb.bluemix.net/oidc/keys"), + ?assertMatch({ok, _}, jwks:get_key(?MODULE, <<"20170401-00:00:00">>)). + +-endif. diff --git a/src/jwtf.erl b/src/jwtf.erl index e63a25823..ec4a19ac8 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -333,6 +333,3 @@ rs256_test() -> ?assertMatch({ok, ExpectedPayload}, decode(EncodedToken, Checks, KS)). -endif. - - - -- cgit v1.2.1 From d7bd8d16f560d3884a7da68e03b3b4eb62544b26 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Mon, 8 May 2017 19:20:01 +0100 Subject: Make typ and alg optional and make everything truly optional. --- src/jwtf.erl | 50 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index ec4a19ac8..f3f41a686 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -27,7 +27,7 @@ decode(EncodedToken, Checks, KS) -> validate(Header0, Payload0, Signature, Checks, KS) -> Header1 = props(decode_json(Header0)), - validate_header(Header1), + validate_header(Header1, Checks), Payload1 = props(decode_json(Payload0)), validate_payload(Payload1, Checks), @@ -37,17 +37,37 @@ validate(Header0, Payload0, Signature, Checks, KS) -> verify(Alg, Header0, Payload0, Signature, Key). -validate_header(Props) -> - case prop(<<"typ">>, Props) of - <<"JWT">> -> +validate_header(Props, Checks) -> + validate_typ(Props, Checks), + validate_alg(Props, Checks). + + +validate_typ(Props, Checks) -> + Required = prop(typ, Checks), + TYP = prop(<<"typ">>, Props), + case {Required, TYP} of + {undefined, _} -> ok; - _ -> + {true, undefined} -> + throw({error, missing_typ}); + {true, <<"JWT">>} -> + ok; + {true, _} -> throw({error, invalid_typ}) - end, - case prop(<<"alg">>, Props) of - <<"RS256">> -> + end. + + +validate_alg(Props, Checks) -> + Required = prop(alg, Checks), + Alg = prop(<<"alg">>, Props), + case {Required, Alg} of + {undefined, _} -> ok; - <<"HS256">> -> + {true, undefined} -> + throw({error, missing_alg}); + {true, <<"RS256">>} -> + ok; + {true, <<"HS256">>} -> ok; _ -> throw({error, invalid_alg}) @@ -82,7 +102,7 @@ validate_iat(Props, Checks) -> IAT = prop(<<"iat">>, Props), case {Required, IAT} of - {undefined, undefined} -> + {undefined, _} -> ok; {true, undefined} -> throw({error, missing_iat}); @@ -96,7 +116,7 @@ validate_nbf(Props, Checks) -> NBF = prop(<<"nbf">>, Props), case {Required, NBF} of - {undefined, undefined} -> + {undefined, _} -> ok; {true, undefined} -> throw({error, missing_nbf}); @@ -110,7 +130,7 @@ validate_exp(Props, Checks) -> EXP = prop(<<"exp">>, Props), case {Required, EXP} of - {undefined, undefined} -> + {undefined, _} -> ok; {true, undefined} -> throw({error, missing_exp}); @@ -123,11 +143,9 @@ key(Props, Checks, KS) -> Required = prop(kid, Checks), KID = prop(<<"kid">>, Props), case {Required, KID} of - {undefined, undefined} -> - KS(undefined); {true, undefined} -> throw({error, missing_kid}); - {true, KID} -> + {_, KID} -> KS(KID) end. @@ -308,7 +326,7 @@ hs256_test() -> "sImtpZCI6ImJhciJ9.lpOvEnYLdcujwo9RbhzXme6J-eQ1yfl782qq" "crR6QYE">>, KS = fun(_) -> <<"secret">> end, - Checks = [{iss, <<"https://foo.com">>}, iat, exp, kid, sig], + Checks = [{iss, <<"https://foo.com">>}, iat, exp, kid, sig, typ, alg], ?assertMatch({ok, _}, decode(EncodedToken, Checks, KS)). -- cgit v1.2.1 From 8077258826f6c53359df22d97a42a323e7d12a6e Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Mon, 8 May 2017 20:13:35 +0100 Subject: use public url --- src/jwks.erl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jwks.erl b/src/jwks.erl index 62bf3ca1d..edd695964 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -135,7 +135,7 @@ decode_number(Base64) -> jwks_test() -> application:start(ibrowse), - jwks:start_link("https://iam.stage1.eu-gb.bluemix.net/oidc/keys"), - ?assertMatch({ok, _}, jwks:get_key(?MODULE, <<"20170401-00:00:00">>)). + jwks:start_link("https://iam.eu-gb.bluemix.net/oidc/keys"), + ?assertMatch({ok, _}, jwks:get_key(?MODULE, <<"20170402-00:00:00">>)). -endif. -- cgit v1.2.1 From 3cb8b7d42475bb9c0f96d075aaa7dffab64a1f7c Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Mon, 8 May 2017 20:30:09 +0100 Subject: 98% coverage --- src/jwtf.erl | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index f3f41a686..e7157f1f4 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -250,11 +250,21 @@ jwt_io_pubkey() -> public_key:pem_entry_decode(PEMEntry). +missing_typ_test() -> + Encoded = encode({[]}, []), + ?assertEqual({error, missing_typ}, decode(Encoded, [typ], nil)). + + invalid_typ_test() -> Encoded = encode({[{<<"typ">>, <<"NOPE">>}]}, []), ?assertEqual({error, invalid_typ}, decode(Encoded, [typ], nil)). +missing_alg_test() -> + Encoded = encode({[{<<"typ">>, <<"NOPE">>}]}, []), + ?assertEqual({error, missing_alg}, decode(Encoded, [alg], nil)). + + invalid_alg_test() -> Encoded = encode({[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"NOPE">>}]}, []), ?assertEqual({error, invalid_alg}, decode(Encoded, [alg], nil)). @@ -300,6 +310,11 @@ invalid_exp_test() -> ?assertEqual({error, {exp,not_in_future}}, decode(Encoded, [exp], nil)). +missing_kid_test() -> + Encoded = encode(valid_header(), {[]}), + ?assertEqual({error, missing_kid}, decode(Encoded, [kid], nil)). + + bad_rs256_sig_test() -> Encoded = encode( {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"RS256">>}]}, @@ -339,7 +354,7 @@ rs256_test() -> "39yxJPAjUESwxk2J5k_4zM3O-vtd1Ghyo4IbqKKSy6J9mTniYJPenn" "5-HIirE">>, - Checks = [sig], + Checks = [sig, alg], KS = fun(undefined) -> jwt_io_pubkey() end, ExpectedPayload = {[ -- cgit v1.2.1 From e60fa5015b5b0debf8be7d95e70c731638d7f2bd Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Tue, 9 May 2017 12:35:29 +0100 Subject: kid belongs in the header --- src/jwtf.erl | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index e7157f1f4..1a1877c03 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -33,7 +33,7 @@ validate(Header0, Payload0, Signature, Checks, KS) -> validate_payload(Payload1, Checks), Alg = prop(<<"alg">>, Header1), - Key = key(Payload1, Checks, KS), + Key = key(Header1, Checks, KS), verify(Alg, Header0, Payload0, Signature, Key). @@ -311,7 +311,7 @@ invalid_exp_test() -> missing_kid_test() -> - Encoded = encode(valid_header(), {[]}), + Encoded = encode({[]}, {[]}), ?assertEqual({error, missing_kid}, decode(Encoded, [kid], nil)). @@ -336,13 +336,13 @@ malformed_token_test() -> hs256_test() -> - EncodedToken = <<"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwc" - "zovL2Zvby5jb20iLCJpYXQiOjAsImV4cCI6MTAwMDAwMDAwMDAwMDA" - "sImtpZCI6ImJhciJ9.lpOvEnYLdcujwo9RbhzXme6J-eQ1yfl782qq" - "crR6QYE">>, - KS = fun(_) -> <<"secret">> end, - Checks = [{iss, <<"https://foo.com">>}, iat, exp, kid, sig, typ, alg], - ?assertMatch({ok, _}, decode(EncodedToken, Checks, KS)). + EncodedToken = <<"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQ1Ni" + "J9.eyJpc3MiOiJodHRwczovL2Zvby5jb20iLCJpYXQiOjAsImV4cCI" + "6MTAwMDAwMDAwMDAwMDAsImtpZCI6ImJhciJ9.iS8AH11QHHlczkBn" + "Hl9X119BYLOZyZPllOVhSBZ4RZs">>, + KS = fun(<<"123456">>) -> <<"secret">> end, + Checks = [{iss, <<"https://foo.com">>}, iat, exp, sig, typ, alg, kid], + ?assertMatch({ok, _}, catch decode(EncodedToken, Checks, KS)). %% jwt.io example -- cgit v1.2.1 From a18a2e5e5c40bb406f67f27b00bb3d206778aefd Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Tue, 9 May 2017 13:50:33 +0100 Subject: some documentation --- src/jwks.erl | 5 +++++ src/jwtf.erl | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/jwks.erl b/src/jwks.erl index edd695964..748c162d8 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -10,6 +10,11 @@ % License for the specific language governing permissions and limitations under % the License. +% @doc +% This module parses JSON Web Key Sets (JWKS) and caches them for +% performance reasons. To use the module, include it in your +% supervision tree. + -module(jwks). -behaviour(gen_server). diff --git a/src/jwtf.erl b/src/jwtf.erl index 1a1877c03..6ec832f73 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -10,10 +10,20 @@ % License for the specific language governing permissions and limitations under % the License. +% @doc +% This module decodes and validates JWT tokens. Almost all property +% checks are optional. If not checked, the presence or validity of the +% field is not verified. Signature check is mandatory, though. + -module(jwtf). -export([decode/3]). +% @doc decode +% Decodes the supplied encoded token, checking +% for the attributes defined in Checks and calling +% the key store function to retrieve the key needed +% to verify the signature decode(EncodedToken, Checks, KS) -> try [Header, Payload, Signature] = split(EncodedToken), -- cgit v1.2.1 From 69e1ce2b3e92f87c4b2ca19c182256d8f9ac1c92 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Tue, 9 May 2017 15:14:11 +0100 Subject: Add stats, don't wipe cache on error --- priv/stats_descriptions.cfg | 12 ++++++++++++ src/jwks.erl | 31 +++++++++++++++++-------------- src/jwtf.app.src | 1 + 3 files changed, 30 insertions(+), 14 deletions(-) create mode 100644 priv/stats_descriptions.cfg diff --git a/priv/stats_descriptions.cfg b/priv/stats_descriptions.cfg new file mode 100644 index 000000000..7aa5cab5d --- /dev/null +++ b/priv/stats_descriptions.cfg @@ -0,0 +1,12 @@ +{[jkws, hit], [ + {type, counter}, + {desc, <<"cache hit for JKWS key lookup">>} +]}. +{[jkws, miss], [ + {type, counter}, + {desc, <<"cache miss for JKWS key lookup">>} +]}. +{[jkws, latency], [ + {type, histogram}, + {desc, <<"distribution of latencies for calls to retrieve JKWS keys">>} +]}. diff --git a/src/jwks.erl b/src/jwks.erl index 748c162d8..1c416dced 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -32,21 +32,25 @@ terminate/2 ]). -start_link(Url) -> - gen_server:start_link({local, ?MODULE}, ?MODULE, Url, []). +start_link(JWKSUrl) -> + gen_server:start_link({local, ?MODULE}, ?MODULE, JWKSUrl, []). get_key(Pid, Kid) -> case lookup(Kid) of {ok, Key} -> - %% couch_stats:increment_counter([jkws, hit]), + couch_stats:increment_counter([jkws, hit]), {ok, Key}; {error, not_found} -> - %% couch_stats:increment_counter([jkws, miss]), + couch_stats:increment_counter([jkws, miss]), Url = gen_server:call(Pid, get_url), - KeySet = get_keyset(Url), - ok = gen_server:call(Pid, {replace_keyset, KeySet}), - lookup(Kid) + case get_keyset(Url) of + {ok, KeySet} -> + ok = gen_server:call(Pid, {replace_keyset, KeySet}), + lookup(Kid); + {error, Reason} -> + {error, Reason} + end end. @@ -99,15 +103,14 @@ terminate(_Reason, _State) -> get_keyset(Url) -> ReqHeaders = [], - %% T0 = os:timestamp(), + T0 = os:timestamp(), case ibrowse:send_req(Url, ReqHeaders, get) of {ok, "200", _RespHeaders, RespBody} -> - %% Latency = timer:now_diff(os:timestamp(), T0) / 1000, - %% couch_stats:update_histogram([jkws, latency], Latency), - parse_keyset(RespBody); - Else -> - io:format("~p", [Else]), - [] + Latency = timer:now_diff(os:timestamp(), T0) / 1000, + couch_stats:update_histogram([jkws, latency], Latency), + {ok, parse_keyset(RespBody)}; + _Else -> + {error, get_keyset_failed} end. diff --git a/src/jwtf.app.src b/src/jwtf.app.src index 304bb9e0a..87d9aafba 100644 --- a/src/jwtf.app.src +++ b/src/jwtf.app.src @@ -18,6 +18,7 @@ kernel, stdlib, b64url, + couch_stats, crypto, jiffy, public_key -- cgit v1.2.1 From 25bfdc3c9a4262d64bed2e11d53997ad0c838551 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Tue, 9 May 2017 17:26:59 +0100 Subject: make jwks simpler, caching can happen elsewhere --- priv/stats_descriptions.cfg | 12 ------ src/jwks.erl | 99 ++------------------------------------------- src/jwtf.app.src | 1 - 3 files changed, 4 insertions(+), 108 deletions(-) delete mode 100644 priv/stats_descriptions.cfg diff --git a/priv/stats_descriptions.cfg b/priv/stats_descriptions.cfg deleted file mode 100644 index 7aa5cab5d..000000000 --- a/priv/stats_descriptions.cfg +++ /dev/null @@ -1,12 +0,0 @@ -{[jkws, hit], [ - {type, counter}, - {desc, <<"cache hit for JKWS key lookup">>} -]}. -{[jkws, miss], [ - {type, counter}, - {desc, <<"cache miss for JKWS key lookup">>} -]}. -{[jkws, latency], [ - {type, histogram}, - {desc, <<"distribution of latencies for calls to retrieve JKWS keys">>} -]}. diff --git a/src/jwks.erl b/src/jwks.erl index 1c416dced..1820ab669 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -11,114 +11,24 @@ % the License. % @doc -% This module parses JSON Web Key Sets (JWKS) and caches them for -% performance reasons. To use the module, include it in your -% supervision tree. +% This module fetches and parses JSON Web Key Sets (JWKS). -module(jwks). --behaviour(gen_server). -export([ - start_link/1, - get_key/2 + get_keyset/1 ]). --export([ - init/1, - handle_call/3, - handle_cast/2, - handle_info/2, - code_change/3, - terminate/2 -]). - -start_link(JWKSUrl) -> - gen_server:start_link({local, ?MODULE}, ?MODULE, JWKSUrl, []). - - -get_key(Pid, Kid) -> - case lookup(Kid) of - {ok, Key} -> - couch_stats:increment_counter([jkws, hit]), - {ok, Key}; - {error, not_found} -> - couch_stats:increment_counter([jkws, miss]), - Url = gen_server:call(Pid, get_url), - case get_keyset(Url) of - {ok, KeySet} -> - ok = gen_server:call(Pid, {replace_keyset, KeySet}), - lookup(Kid); - {error, Reason} -> - {error, Reason} - end - end. - - -lookup(Kid) -> - case ets:lookup(?MODULE, Kid) of - [{Kid, Key}] -> - {ok, Key}; - [] -> - {error, not_found} - end. - - - -%% gen_server functions - -init(Url) -> - ?MODULE = ets:new(?MODULE, [protected, named_table, {read_concurrency, true}]), - KeySet = get_keyset(Url), - set_keyset(KeySet), - {ok, Url}. - - -handle_call({replace_keyset, KeySet}, _From, State) -> - set_keyset(KeySet), - {reply, ok, State}; - -handle_call(get_url, _From, State) -> - {reply, State, State}; - -handle_call(_Msg, _From, State) -> - {noreply, State}. - - -handle_cast(_Msg, State) -> - {noreply, State}. - - -handle_info(_Msg, State) -> - {noreply, State}. - - -code_change(_OldVsn, State, _Extra) -> - {ok, State}. - - -terminate(_Reason, _State) -> - ok. - -%% private functions - get_keyset(Url) -> ReqHeaders = [], - T0 = os:timestamp(), case ibrowse:send_req(Url, ReqHeaders, get) of {ok, "200", _RespHeaders, RespBody} -> - Latency = timer:now_diff(os:timestamp(), T0) / 1000, - couch_stats:update_histogram([jkws, latency], Latency), {ok, parse_keyset(RespBody)}; _Else -> {error, get_keyset_failed} end. -set_keyset(KeySet) -> - true = ets:delete_all_objects(?MODULE), - true = ets:insert(?MODULE, KeySet). - - parse_keyset(Body) -> {Props} = jiffy:decode(Body), Keys = proplists:get_value(<<"keys">>, Props), @@ -142,8 +52,7 @@ decode_number(Base64) -> -include_lib("eunit/include/eunit.hrl"). jwks_test() -> - application:start(ibrowse), - jwks:start_link("https://iam.eu-gb.bluemix.net/oidc/keys"), - ?assertMatch({ok, _}, jwks:get_key(?MODULE, <<"20170402-00:00:00">>)). + application:ensure_all_started(ibrowse), + ?assertMatch({ok, _}, get_keyset("https://iam.eu-gb.bluemix.net/oidc/keys")). -endif. diff --git a/src/jwtf.app.src b/src/jwtf.app.src index 87d9aafba..304bb9e0a 100644 --- a/src/jwtf.app.src +++ b/src/jwtf.app.src @@ -18,7 +18,6 @@ kernel, stdlib, b64url, - couch_stats, crypto, jiffy, public_key -- cgit v1.2.1 From 31999f40e1c4acecab3a317dcdb9e08783d9b0d2 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Tue, 9 May 2017 20:07:15 +0100 Subject: allow iss to be optional --- src/jwtf.erl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index 6ec832f73..b03fa91c4 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -98,7 +98,9 @@ validate_iss(Props, Checks) -> ActualISS = prop(<<"iss">>, Props), case {ExpectedISS, ActualISS} of - {ISS, undefined} when ISS /= undefined -> + {undefined, _} -> + ok; + {_ISS, undefined} -> throw({error, missing_iss}); {ISS, ISS} -> ok; -- cgit v1.2.1 From acbaa3731b7a1131b1116df5cb1cd3d86ddc2534 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Tue, 9 May 2017 22:36:02 +0100 Subject: slightly improve readme --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 84e196ad8..27e1e788e 100644 --- a/README.md +++ b/README.md @@ -6,5 +6,7 @@ This library provides JWT parsing and validation functions Supports; -# Verify -# RS256 +* Verify +* RS256 +* HS256 + -- cgit v1.2.1 From bf7a2edac9024696f6ba4d0092e45cf071815e71 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Wed, 10 May 2017 18:06:13 +0100 Subject: expand algorithm support --- src/jwks.erl | 50 ++++++++++++++++++++++++++++++++++++++++++++------ src/jwtf.erl | 54 ++++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 86 insertions(+), 18 deletions(-) diff --git a/src/jwks.erl b/src/jwks.erl index 1820ab669..8b72ac85c 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -19,6 +19,8 @@ get_keyset/1 ]). +-include_lib("public_key/include/public_key.hrl"). + get_keyset(Url) -> ReqHeaders = [], case ibrowse:send_req(Url, ReqHeaders, get) of @@ -32,16 +34,23 @@ get_keyset(Url) -> parse_keyset(Body) -> {Props} = jiffy:decode(Body), Keys = proplists:get_value(<<"keys">>, Props), - [parse_key(Key) || Key <- Keys]. + lists:flatmap(fun parse_key/1, Keys). parse_key({Props}) -> - <<"RS256">> = proplists:get_value(<<"alg">>, Props), - <<"RSA">> = proplists:get_value(<<"kty">>, Props), + Alg = proplists:get_value(<<"alg">>, Props), + Kty = proplists:get_value(<<"kty">>, Props), Kid = proplists:get_value(<<"kid">>, Props), - E = proplists:get_value(<<"e">>, Props), - N = proplists:get_value(<<"n">>, Props), - {Kid, {'RSAPublicKey', decode_number(N), decode_number(E)}}. + case {Alg, Kty} of + {<<"RS256">>, <<"RSA">>} -> + E = proplists:get_value(<<"e">>, Props), + N = proplists:get_value(<<"n">>, Props), + [{{Kty, Kid}, #'RSAPublicKey'{ + modulus = decode_number(N), + publicExponent = decode_number(E)}}]; + _ -> + [] + end. decode_number(Base64) -> @@ -55,4 +64,33 @@ jwks_test() -> application:ensure_all_started(ibrowse), ?assertMatch({ok, _}, get_keyset("https://iam.eu-gb.bluemix.net/oidc/keys")). +rs_test() -> + Ejson = {[ + {<<"kty">>, <<"RSA">>}, + {<<"n">>, <<"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx" + "4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMs" + "tn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2" + "QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbI" + "SD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqb" + "w0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw">>}, + {<<"e">>, <<"AQAB">>}, + {<<"alg">>, <<"RS256">>}, + {<<"kid">>, <<"2011-04-29">>} + ]}, + ?assertMatch([{{<<"RSA">>, <<"2011-04-29">>}, {'RSAPublicKey', _, 65537}}], + parse_key(Ejson)). + + +ec_test() -> + Ejson = {[ + {<<"kty">>, <<"EC">>}, + {<<"crv">>, <<"P-256">>}, + {<<"x">>, <<"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4">>}, + {<<"y">>, <<"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM">>}, + {<<"alg">>, <<"ES256">>}, + {<<"kid">>, <<"1">>} + ]}, + %% TODO figure out how to convert x,y to an ECPoint. + ?assertMatch([], parse_key(Ejson)). + -endif. diff --git a/src/jwtf.erl b/src/jwtf.erl index b03fa91c4..18f84deb7 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -70,17 +70,31 @@ validate_typ(Props, Checks) -> validate_alg(Props, Checks) -> Required = prop(alg, Checks), Alg = prop(<<"alg">>, Props), + Valid = [ + <<"RS256">>, + <<"RS384">>, + <<"RS512">>, + + <<"HS256">>, + <<"HS384">>, + <<"HS512">>, + + <<"ES384">>, + <<"ES512">>, + <<"ES512">> + ], case {Required, Alg} of {undefined, _} -> ok; {true, undefined} -> throw({error, missing_alg}); - {true, <<"RS256">>} -> - ok; - {true, <<"HS256">>} -> - ok; - _ -> - throw({error, invalid_alg}) + {true, Alg} -> + case lists:member(Alg, Valid) of + true -> + ok; + false -> + throw({error, invalid_alg}) + end end. @@ -167,14 +181,30 @@ verify(Alg, Header, Payload, Signature0, Key) -> Signature1 = b64url:decode(Signature0), case Alg of <<"RS256">> -> - rs256_verify(Message, Signature1, Key); + public_key_verify(sha256, Message, Signature1, Key); + <<"RS384">> -> + public_key_verify(sha384, Message, Signature1, Key); + <<"RS512">> -> + public_key_verify(sha512, Message, Signature1, Key); + + <<"ES256">> -> + public_key_verify(sha256, Message, Signature1, Key); + <<"ES384">> -> + public_key_verify(sha384, Message, Signature1, Key); + <<"ES512">> -> + public_key_verify(sha512, Message, Signature1, Key); + <<"HS256">> -> - hs256_verify(Message, Signature1, Key) + hmac_verify(sha256, Message, Signature1, Key); + <<"HS384">> -> + hmac_verify(sha384, Message, Signature1, Key); + <<"HS512">> -> + hmac_verify(sha512, Message, Signature1, Key) end. -rs256_verify(Message, Signature, PublicKey) -> - case public_key:verify(Message, sha256, Signature, PublicKey) of +public_key_verify(Alg, Message, Signature, PublicKey) -> + case public_key:verify(Message, Alg, Signature, PublicKey) of true -> ok; false -> @@ -182,8 +212,8 @@ rs256_verify(Message, Signature, PublicKey) -> end. -hs256_verify(Message, HMAC, SecretKey) -> - case crypto:hmac(sha256, SecretKey, Message) of +hmac_verify(Alg, Message, HMAC, SecretKey) -> + case crypto:hmac(Alg, SecretKey, Message) of HMAC -> ok; _ -> -- cgit v1.2.1 From 61f47b34cb764f9e392c3f3f18651e7cb01ef9ab Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Wed, 10 May 2017 18:50:22 +0100 Subject: support P-256 in JWKS --- src/jwks.erl | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/jwks.erl b/src/jwks.erl index 8b72ac85c..d1863303c 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -48,6 +48,18 @@ parse_key({Props}) -> [{{Kty, Kid}, #'RSAPublicKey'{ modulus = decode_number(N), publicExponent = decode_number(E)}}]; + {<<"ES256">>, <<"EC">>} -> + Crv = proplists:get_value(<<"crv">>, Props), + case Crv of + <<"P-256">> -> + X = proplists:get_value(<<"x">>, Props), + Y = proplists:get_value(<<"y">>, Props), + Point = <<4:8, X/binary, Y/binary>>, + [{{Kty, Kid}, #'ECPoint'{ + point = Point}}]; + _ -> + [] + end; _ -> [] end. @@ -91,6 +103,6 @@ ec_test() -> {<<"kid">>, <<"1">>} ]}, %% TODO figure out how to convert x,y to an ECPoint. - ?assertMatch([], parse_key(Ejson)). + ?assertMatch([{{<<"EC">>, <<"1">>}, {'ECPoint', _}}], parse_key(Ejson)). -endif. -- cgit v1.2.1 From 373a3671fa576d762e4dab89a655b9536885a15f Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Wed, 10 May 2017 18:54:17 +0100 Subject: update alg list --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 27e1e788e..e6038fbc0 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,11 @@ Supports; * Verify * RS256 +* RS384 +* RS512 * HS256 - +* HS384 +* HS512 +* ES256 +* ES384 +* ES512 -- cgit v1.2.1 From ae0e0f495db22069e6c811462cd974fea7ae7ad8 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Wed, 10 May 2017 19:51:17 +0100 Subject: return a public key tuple --- src/jwks.erl | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/jwks.erl b/src/jwks.erl index d1863303c..a2231b2f4 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -55,8 +55,10 @@ parse_key({Props}) -> X = proplists:get_value(<<"x">>, Props), Y = proplists:get_value(<<"y">>, Props), Point = <<4:8, X/binary, Y/binary>>, - [{{Kty, Kid}, #'ECPoint'{ - point = Point}}]; + [{{Kty, Kid}, { + #'ECPoint'{point = Point}, + {namedCurve, secp256r1} + }}]; _ -> [] end; @@ -103,6 +105,7 @@ ec_test() -> {<<"kid">>, <<"1">>} ]}, %% TODO figure out how to convert x,y to an ECPoint. - ?assertMatch([{{<<"EC">>, <<"1">>}, {'ECPoint', _}}], parse_key(Ejson)). + ?assertMatch([{{<<"EC">>, <<"1">>}, {{'ECPoint', _}, + {namedCurve, secp256r1}}}], parse_key(Ejson)). -endif. -- cgit v1.2.1 From e0d61d06651b576b9b0a36600529028aae334e68 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Wed, 10 May 2017 21:54:21 +0100 Subject: test EC --- src/jwks.erl | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/jwks.erl b/src/jwks.erl index a2231b2f4..b88c59068 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -54,10 +54,12 @@ parse_key({Props}) -> <<"P-256">> -> X = proplists:get_value(<<"x">>, Props), Y = proplists:get_value(<<"y">>, Props), - Point = <<4:8, X/binary, Y/binary>>, + Point = <<4:8, + (b64url:decode(X))/binary, + (b64url:decode(Y))/binary>>, [{{Kty, Kid}, { #'ECPoint'{point = Point}, - {namedCurve, secp256r1} + {namedCurve,{1,2,840,10045,3,1,7}} }}]; _ -> [] @@ -96,6 +98,13 @@ rs_test() -> ec_test() -> + PrivateKey = #'ECPrivateKey'{ + version = 1, + parameters = {namedCurve,{1,2,840,10045,3,1,7}}, + privateKey = b64url:decode("870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE"), + publicKey = <<4:8, + (b64url:decode("MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4"))/binary, + (b64url:decode("4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM"))/binary>>}, Ejson = {[ {<<"kty">>, <<"EC">>}, {<<"crv">>, <<"P-256">>}, @@ -104,8 +113,10 @@ ec_test() -> {<<"alg">>, <<"ES256">>}, {<<"kid">>, <<"1">>} ]}, - %% TODO figure out how to convert x,y to an ECPoint. - ?assertMatch([{{<<"EC">>, <<"1">>}, {{'ECPoint', _}, - {namedCurve, secp256r1}}}], parse_key(Ejson)). + ?assertMatch([{_Key, _Value}], parse_key(Ejson)), + {_, ECPublicKey} = parse_key(Ejson), + Msg = <<"foo">>, + Sig = public_key:sign(Msg, sha256, PrivateKey), + ?assert(public_key:verify(Msg, sha256, Sig, ECPublicKey)). -endif. -- cgit v1.2.1 From e180555734f84612b3a6df8addf59aa6cfc89f63 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Wed, 10 May 2017 22:04:03 +0100 Subject: fix test --- src/jwks.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jwks.erl b/src/jwks.erl index b88c59068..d694d2e7b 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -114,7 +114,7 @@ ec_test() -> {<<"kid">>, <<"1">>} ]}, ?assertMatch([{_Key, _Value}], parse_key(Ejson)), - {_, ECPublicKey} = parse_key(Ejson), + [{_, ECPublicKey}] = parse_key(Ejson), Msg = <<"foo">>, Sig = public_key:sign(Msg, sha256, PrivateKey), ?assert(public_key:verify(Msg, sha256, Sig, ECPublicKey)). -- cgit v1.2.1 From e80c3d168c835adea87469ca53dec0d54bab7023 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Thu, 11 May 2017 09:28:40 +0100 Subject: add tests for HS384 and HS512 --- src/jwtf.erl | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index 18f84deb7..ae8239a9a 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -377,17 +377,40 @@ malformed_token_test() -> ?assertEqual({error, malformed_token}, decode(<<"a.b.c.d">>, [], nil)). +%% jwt.io generated hs256_test() -> EncodedToken = <<"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQ1Ni" "J9.eyJpc3MiOiJodHRwczovL2Zvby5jb20iLCJpYXQiOjAsImV4cCI" "6MTAwMDAwMDAwMDAwMDAsImtpZCI6ImJhciJ9.iS8AH11QHHlczkBn" "Hl9X119BYLOZyZPllOVhSBZ4RZs">>, KS = fun(<<"123456">>) -> <<"secret">> end, - Checks = [{iss, <<"https://foo.com">>}, iat, exp, sig, typ, alg, kid], + Checks = [{iss, <<"https://foo.com">>}, iat, exp, typ, alg, kid], ?assertMatch({ok, _}, catch decode(EncodedToken, Checks, KS)). -%% jwt.io example +%% pip install PyJWT +%% > import jwt +%% > jwt.encode({'foo':'bar'}, 'secret', algorithm='HS384') +hs384_test() -> + EncodedToken = <<"eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIif" + "Q.2quwghs6I56GM3j7ZQbn-ASZ53xdBqzPzTDHm_CtVec32LUy-Ezy" + "L3JjIe7WjL93">>, + KS = fun(_) -> <<"secret">> end, + ?assertMatch({ok, {[{<<"foo">>,<<"bar">>}]}}, catch decode(EncodedToken, [], KS)). + + +%% pip install PyJWT +%% > import jwt +%% > jwt.encode({'foo':'bar'}, 'secret', algorithm='HS512') +hs512_test() -> + EncodedToken = <<"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYX" + "IifQ.WePl7achkd0oGNB8XRF_LJwxlyiPZqpdNgdKpDboAjSTsW" + "q-aOGNynTp8TOv8KjonFym8vwFwppXOLoLXbkIaQ">>, + KS = fun(_) -> <<"secret">> end, + ?assertMatch({ok, {[{<<"foo">>,<<"bar">>}]}}, catch decode(EncodedToken, [], KS)). + + +%% jwt.io generated rs256_test() -> EncodedToken = <<"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0N" "TY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.Ek" @@ -407,4 +430,5 @@ rs256_test() -> ?assertMatch({ok, ExpectedPayload}, decode(EncodedToken, Checks, KS)). + -endif. -- cgit v1.2.1 From 6cc182d5bd009c0bfee036651714a3294bfa2254 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Thu, 11 May 2017 09:33:14 +0100 Subject: IAT validation requires it to be a number, any number --- src/jwtf.erl | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index ae8239a9a..cffe88b00 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -132,8 +132,10 @@ validate_iat(Props, Checks) -> ok; {true, undefined} -> throw({error, missing_iat}); - {true, IAT} -> - assert_past(iat, IAT) + {true, IAT} when is_integer(IAT) -> + ok; + {true, _} -> + throw({error, invalid_iat}) end. @@ -328,8 +330,8 @@ missing_iat_test() -> invalid_iat_test() -> - Encoded = encode(valid_header(), {[{<<"iat">>, 32503680000}]}), - ?assertEqual({error, {iat,not_in_past}}, decode(Encoded, [iat], nil)). + Encoded = encode(valid_header(), {[{<<"iat">>, <<"hello">>}]}), + ?assertEqual({error, invalid_iat}, decode(Encoded, [iat], nil)). missing_nbf_test() -> -- cgit v1.2.1 From e083b22e2a66fc8ce965c09757a4fd42f333a982 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Thu, 11 May 2017 10:40:12 +0100 Subject: provide caching of JWKS keys --- src/jwks.erl | 31 +++++++++++++++++++++++++++++ src/jwtf.app.src | 2 ++ src/jwtf_app.erl | 26 ++++++++++++++++++++++++ src/jwtf_sup.erl | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+) create mode 100644 src/jwtf_app.erl create mode 100644 src/jwtf_sup.erl diff --git a/src/jwks.erl b/src/jwks.erl index d694d2e7b..d6c44deb4 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -16,11 +16,42 @@ -module(jwks). -export([ + get_key/3, get_keyset/1 ]). -include_lib("public_key/include/public_key.hrl"). +get_key(Url, Kty, Kid) -> + case lookup(Url, Kty, Kid) of + {ok, Key} -> + {ok, Key}; + {error, not_found} -> + update_cache(Url), + lookup(Url, Kty, Kid) + end. + + +lookup(Url, Kty, Kid) -> + case ets_lru:lookup_d(jwks_cache_lru, {Url, Kty, Kid}) of + {ok, Key} -> + {ok, Key}; + not_found -> + {error, not_found} + end. + + +update_cache(Url) -> + case get_keyset(Url) of + {ok, KeySet} -> + [ets_lru:insert(jwks_cache_lru, {Url, Kty, Kid}, Key) + || {{Kty, Kid}, Key} <- KeySet], + ok; + {error, Reason} -> + {error, Reason} + end. + + get_keyset(Url) -> ReqHeaders = [], case ibrowse:send_req(Url, ReqHeaders, get) of diff --git a/src/jwtf.app.src b/src/jwtf.app.src index 304bb9e0a..5fd9c2562 100644 --- a/src/jwtf.app.src +++ b/src/jwtf.app.src @@ -14,11 +14,13 @@ {description, "JSON Web Token Functions"}, {vsn, git}, {registered, []}, + {mod, { jwtf_app, []}}, {applications, [ kernel, stdlib, b64url, crypto, + ets_lru, jiffy, public_key ]}, diff --git a/src/jwtf_app.erl b/src/jwtf_app.erl new file mode 100644 index 000000000..92a26d558 --- /dev/null +++ b/src/jwtf_app.erl @@ -0,0 +1,26 @@ +%%%------------------------------------------------------------------- +%% @doc jwtf public API +%% @end +%%%------------------------------------------------------------------- + +-module(jwtf_app). + +-behaviour(application). + +%% Application callbacks +-export([start/2, stop/1]). + +%%==================================================================== +%% API +%%==================================================================== + +start(_StartType, _StartArgs) -> + jwtf_sup:start_link(). + +%%-------------------------------------------------------------------- +stop(_State) -> + ok. + +%%==================================================================== +%% Internal functions +%%==================================================================== diff --git a/src/jwtf_sup.erl b/src/jwtf_sup.erl new file mode 100644 index 000000000..2256ac53a --- /dev/null +++ b/src/jwtf_sup.erl @@ -0,0 +1,60 @@ +%%%------------------------------------------------------------------- +%% @doc epep top level supervisor. +%% @end +%%%------------------------------------------------------------------- + +-module(jwtf_sup). + +-behaviour(supervisor). + +%% API +-export([start_link/0]). + +%% Supervisor callbacks +-export([init/1]). + +-define(SERVER, ?MODULE). + +%%==================================================================== +%% API functions +%%==================================================================== + +start_link() -> + supervisor:start_link({local, ?SERVER}, ?MODULE, []). + +%%==================================================================== +%% Supervisor callbacks +%%==================================================================== + +%% Child :: {Id,StartFunc,Restart,Shutdown,Type,Modules} +init([]) -> + Children = [ + {jwks_cache_lru, + {ets_lru, start_link, [jwks_cache_lru, lru_opts()]}, + permanent, 5000, worker, [ets_lru]} + ], + {ok, { {one_for_all, 0, 1}, Children} }. + +%%==================================================================== +%% Internal functions +%%==================================================================== + +lru_opts() -> + case config:get_integer("jwtf_cache", "max_objects", 50) of + MxObjs when MxObjs > 0 -> + [{max_objects, MxObjs}]; + _ -> + [] + end ++ + case config:get_integer("jwtf_cache", "max_size", 0) of + MxSize when MxSize > 0 -> + [{max_size, MxSize}]; + _ -> + [] + end ++ + case config:get_integer("jwtf_cache", "max_lifetime", 0) of + MxLT when MxLT > 0 -> + [{max_lifetime, MxLT}]; + _ -> + [] + end. -- cgit v1.2.1 From 9d60fa25bec69621de6aa9df786e9c739783c754 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Fri, 12 May 2017 10:01:47 +0100 Subject: add ibrowse as dep --- src/jwtf.app.src | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jwtf.app.src b/src/jwtf.app.src index 5fd9c2562..2ff221309 100644 --- a/src/jwtf.app.src +++ b/src/jwtf.app.src @@ -21,6 +21,7 @@ b64url, crypto, ets_lru, + ibrowse, jiffy, public_key ]}, -- cgit v1.2.1 From ceeb019ebbc1d6aadb44b7f55d112e806403ce53 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Fri, 12 May 2017 10:57:02 +0100 Subject: require alg+kid for key lookup --- src/jwtf.erl | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index cffe88b00..ae1b95a2b 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -168,13 +168,14 @@ validate_exp(Props, Checks) -> key(Props, Checks, KS) -> + Alg = prop(<<"alg">>, Props), Required = prop(kid, Checks), KID = prop(<<"kid">>, Props), case {Required, KID} of {true, undefined} -> throw({error, missing_kid}); {_, KID} -> - KS(KID) + KS(Alg, KID) end. @@ -363,7 +364,7 @@ bad_rs256_sig_test() -> Encoded = encode( {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"RS256">>}]}, {[]}), - KS = fun(undefined) -> jwt_io_pubkey() end, + KS = fun(<<"RS256">>, undefined) -> jwt_io_pubkey() end, ?assertEqual({error, bad_signature}, decode(Encoded, [], KS)). @@ -371,7 +372,7 @@ bad_hs256_sig_test() -> Encoded = encode( {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"HS256">>}]}, {[]}), - KS = fun(undefined) -> <<"bad">> end, + KS = fun(<<"HS256">>, undefined) -> <<"bad">> end, ?assertEqual({error, bad_hmac}, decode(Encoded, [], KS)). @@ -385,7 +386,7 @@ hs256_test() -> "J9.eyJpc3MiOiJodHRwczovL2Zvby5jb20iLCJpYXQiOjAsImV4cCI" "6MTAwMDAwMDAwMDAwMDAsImtpZCI6ImJhciJ9.iS8AH11QHHlczkBn" "Hl9X119BYLOZyZPllOVhSBZ4RZs">>, - KS = fun(<<"123456">>) -> <<"secret">> end, + KS = fun(<<"HS256">>, <<"123456">>) -> <<"secret">> end, Checks = [{iss, <<"https://foo.com">>}, iat, exp, typ, alg, kid], ?assertMatch({ok, _}, catch decode(EncodedToken, Checks, KS)). @@ -397,7 +398,7 @@ hs384_test() -> EncodedToken = <<"eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIif" "Q.2quwghs6I56GM3j7ZQbn-ASZ53xdBqzPzTDHm_CtVec32LUy-Ezy" "L3JjIe7WjL93">>, - KS = fun(_) -> <<"secret">> end, + KS = fun(<<"HS384">>, _) -> <<"secret">> end, ?assertMatch({ok, {[{<<"foo">>,<<"bar">>}]}}, catch decode(EncodedToken, [], KS)). @@ -408,7 +409,7 @@ hs512_test() -> EncodedToken = <<"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYX" "IifQ.WePl7achkd0oGNB8XRF_LJwxlyiPZqpdNgdKpDboAjSTsW" "q-aOGNynTp8TOv8KjonFym8vwFwppXOLoLXbkIaQ">>, - KS = fun(_) -> <<"secret">> end, + KS = fun(<<"HS512">>, _) -> <<"secret">> end, ?assertMatch({ok, {[{<<"foo">>,<<"bar">>}]}}, catch decode(EncodedToken, [], KS)). @@ -422,7 +423,7 @@ rs256_test() -> "5-HIirE">>, Checks = [sig, alg], - KS = fun(undefined) -> jwt_io_pubkey() end, + KS = fun(<<"RS256">>, undefined) -> jwt_io_pubkey() end, ExpectedPayload = {[ {<<"sub">>, <<"1234567890">>}, -- cgit v1.2.1 From 5b31b0d79aa2c0fefefb0b35e2e3fab9822eca94 Mon Sep 17 00:00:00 2001 From: Jay Doane Date: Wed, 24 May 2017 09:37:01 -0700 Subject: Improve pubkey not found error handling (#4) * Improve pubkey not found error handling When the public key identified by the {Alg, KID} tuple is not found on the IAM keystore server, it's possible to see errors like: (node1@127.0.0.1)140> epep:jwt_decode(SampleJWT). ** exception error: no function clause matching public_key:do_verify(<<"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjIwMTcwNTIwLTAwOjAwOjAwIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEyMzIx"...>>, sha256, <<229,188,162,247,201,233,118,32,115,206,156, 169,17,221,78,157,161,147,46,179,42,219,66, 15,139,91,...>>, {error,not_found}) (public_key.erl, line 782) in function jwtf:public_key_verify/4 (src/jwtf.erl, line 212) in call from jwtf:decode/3 (src/jwtf.erl, line 30) Modify key/1 and public_key_not_found_test/0 to account for keystore changing from returning an error tuple to throwing one. --- src/jwtf.erl | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/jwtf.erl b/src/jwtf.erl index ae1b95a2b..78b36a9c3 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -360,6 +360,15 @@ missing_kid_test() -> ?assertEqual({error, missing_kid}, decode(Encoded, [kid], nil)). +public_key_not_found_test() -> + Encoded = encode( + {[{<<"alg">>, <<"RS256">>}, {<<"kid">>, <<"1">>}]}, + {[]}), + KS = fun(_, _) -> throw({error, not_found}) end, + Expected = {error, not_found}, + ?assertEqual(Expected, decode(Encoded, [], KS)). + + bad_rs256_sig_test() -> Encoded = encode( {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"RS256">>}]}, -- cgit v1.2.1 From 80d4a643d47ae2f522feceed0be308809518112e Mon Sep 17 00:00:00 2001 From: Jay Doane Date: Mon, 29 May 2017 21:13:48 -0700 Subject: Improve restart strategy Tolerate 5 crashes per 10 seconds --- src/jwtf_sup.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jwtf_sup.erl b/src/jwtf_sup.erl index 2256ac53a..7cf56e84f 100644 --- a/src/jwtf_sup.erl +++ b/src/jwtf_sup.erl @@ -33,7 +33,7 @@ init([]) -> {ets_lru, start_link, [jwks_cache_lru, lru_opts()]}, permanent, 5000, worker, [ets_lru]} ], - {ok, { {one_for_all, 0, 1}, Children} }. + {ok, { {one_for_all, 5, 10}, Children} }. %%==================================================================== %% Internal functions -- cgit v1.2.1 From b396a1d1bc818c5138d78e74668ac94be1ef8dd1 Mon Sep 17 00:00:00 2001 From: Jay Doane Date: Thu, 8 Jun 2017 13:39:02 -0700 Subject: Generate rsa private keys and keypairs --- src/jwtf_test_util.erl | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/jwtf_test_util.erl diff --git a/src/jwtf_test_util.erl b/src/jwtf_test_util.erl new file mode 100644 index 000000000..c32ea1cb9 --- /dev/null +++ b/src/jwtf_test_util.erl @@ -0,0 +1,82 @@ +% Licensed under the Apache License, Version 2.0 (the "License"); you may not +% use this file except in compliance with the License. You may obtain a copy of +% the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +% License for the specific language governing permissions and limitations under +% the License. + +-module(jwtf_test_util). + +-export([ + create_private_key/0, + create_keypair/0, + to_public_key/1 +]). + +-include_lib("public_key/include/public_key.hrl"). + +-spec create_private_key() -> + #'RSAPrivateKey'{} | no_return(). +create_private_key() -> + create_private_key("/tmp"). + + +-spec create_keypair() -> + {#'RSAPrivateKey'{}, #'RSAPublicKey'{}} | no_return(). +create_keypair() -> + PrivateKey = create_private_key(), + {PrivateKey, to_public_key(PrivateKey)}. + + +-spec to_public_key(#'RSAPrivateKey'{}) -> + #'RSAPublicKey'{}. +to_public_key(#'RSAPrivateKey'{} = PrivateKey) -> + #'RSAPublicKey'{ + modulus = PrivateKey#'RSAPrivateKey'.modulus, + publicExponent = PrivateKey#'RSAPrivateKey'.publicExponent}. + + +create_private_key(TmpDir) -> + ok = verify_openssl(), + Path = filename:join(TmpDir, timestamp() ++ "-rsa.key.der"), + Bin = create_rsa_key(Path), + public_key:der_decode('RSAPrivateKey', Bin). + + +verify_openssl() -> + case os:cmd("openssl version") of + "OpenSSL 1." ++ _Rest -> + ok; + _ -> + throw({error, openssl_required}) + end. + + +timestamp() -> + lists:concat([integer_to_list(N) || N <- tuple_to_list(os:timestamp())]). + + +create_rsa_key(Path) -> + Cmd = "openssl genpkey -algorithm RSA -outform DER -out " ++ Path, + Out = os:cmd(Cmd), + %% Since os:cmd doesn't indicate if the command fails, we go to + %% some length to ensure the output looks correct. + ok = validate_genpkey_output(Out), + {ok, Bin} = file:read_file(Path), + ok = file:delete(Path), + Bin. + + +validate_genpkey_output(Out) when is_list(Out) -> + Length = length(Out), + case re:run(Out, "[.+\n]+") of % should only contain period, plus, or nl + {match, [{0, Length}]} -> + ok; + _ -> + throw({error, {openssl_genpkey_failed, Out}}) + end. -- cgit v1.2.1 From d9a718b8cbb68259b3611b44e1eeac9f4b15e0e1 Mon Sep 17 00:00:00 2001 From: Jay Doane Date: Thu, 8 Jun 2017 13:41:12 -0700 Subject: Support JWT encoding Implement jwtf:encode/3 for encoding JSON Web Tokens. Test encode/decode round trip for each supported alg. --- src/jwtf.erl | 159 +++++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 117 insertions(+), 42 deletions(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index 78b36a9c3..a461da98d 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -17,7 +17,53 @@ -module(jwtf). --export([decode/3]). +-export([ + encode/3, + decode/3 +]). + +-define(ALGS, [ + {<<"RS256">>, {public_key, sha256}}, % RSA PKCS#1 signature with SHA-256 + {<<"RS384">>, {public_key, sha384}}, + {<<"RS512">>, {public_key, sha512}}, + {<<"ES256">>, {public_key, sha256}}, + {<<"ES384">>, {public_key, sha384}}, + {<<"ES512">>, {public_key, sha512}}, + {<<"HS256">>, {hmac, sha256}}, + {<<"HS384">>, {hmac, sha384}}, + {<<"HS512">>, {hmac, sha512}}]). + +-define(VALID_ALGS, proplists:get_keys(?ALGS)). + + +% @doc encode +% Encode the JSON Header and Claims using Key and Alg obtained from Header +-spec encode(term(), term(), term()) -> + {ok, binary()} | no_return(). +encode(Header = {HeaderProps}, Claims, Key) -> + try + Alg = case prop(<<"alg">>, HeaderProps) of + undefined -> + throw(missing_alg); + Val -> + Val + end, + EncodedHeader = b64url:encode(jiffy:encode(Header)), + EncodedClaims = b64url:encode(jiffy:encode(Claims)), + Message = <>, + SignatureOrMac = case verification_algorithm(Alg) of + {public_key, Algorithm} -> + public_key:sign(Message, Algorithm, Key); + {hmac, Algorithm} -> + crypto:hmac(Algorithm, Key, Message) + end, + EncodedSignatureOrMac = b64url:encode(SignatureOrMac), + {ok, <>} + catch + throw:Error -> + {error, Error} + end. + % @doc decode % Decodes the supplied encoded token, checking @@ -35,6 +81,19 @@ decode(EncodedToken, Checks, KS) -> end. +% @doc verification_algorithm +% Return {VerificationMethod, Algorithm} tuple for the specified Alg +-spec verification_algorithm(binary()) -> + {atom(), atom()} | no_return(). +verification_algorithm(Alg) -> + case lists:keyfind(Alg, 1, ?ALGS) of + {Alg, Val} -> + Val; + false -> + throw(invalid_alg) + end. + + validate(Header0, Payload0, Signature, Checks, KS) -> Header1 = props(decode_json(Header0)), validate_header(Header1, Checks), @@ -70,26 +129,13 @@ validate_typ(Props, Checks) -> validate_alg(Props, Checks) -> Required = prop(alg, Checks), Alg = prop(<<"alg">>, Props), - Valid = [ - <<"RS256">>, - <<"RS384">>, - <<"RS512">>, - - <<"HS256">>, - <<"HS384">>, - <<"HS512">>, - - <<"ES384">>, - <<"ES512">>, - <<"ES512">> - ], case {Required, Alg} of {undefined, _} -> ok; {true, undefined} -> throw({error, missing_alg}); {true, Alg} -> - case lists:member(Alg, Valid) of + case lists:member(Alg, ?VALID_ALGS) of true -> ok; false -> @@ -179,35 +225,20 @@ key(Props, Checks, KS) -> end. -verify(Alg, Header, Payload, Signature0, Key) -> +verify(Alg, Header, Payload, SignatureOrMac0, Key) -> Message = <
>, - Signature1 = b64url:decode(Signature0), - case Alg of - <<"RS256">> -> - public_key_verify(sha256, Message, Signature1, Key); - <<"RS384">> -> - public_key_verify(sha384, Message, Signature1, Key); - <<"RS512">> -> - public_key_verify(sha512, Message, Signature1, Key); - - <<"ES256">> -> - public_key_verify(sha256, Message, Signature1, Key); - <<"ES384">> -> - public_key_verify(sha384, Message, Signature1, Key); - <<"ES512">> -> - public_key_verify(sha512, Message, Signature1, Key); - - <<"HS256">> -> - hmac_verify(sha256, Message, Signature1, Key); - <<"HS384">> -> - hmac_verify(sha384, Message, Signature1, Key); - <<"HS512">> -> - hmac_verify(sha512, Message, Signature1, Key) + SignatureOrMac1 = b64url:decode(SignatureOrMac0), + {VerificationMethod, Algorithm} = verification_algorithm(Alg), + case VerificationMethod of + public_key -> + public_key_verify(Algorithm, Message, SignatureOrMac1, Key); + hmac -> + hmac_verify(Algorithm, Message, SignatureOrMac1, Key) end. -public_key_verify(Alg, Message, Signature, PublicKey) -> - case public_key:verify(Message, Alg, Signature, PublicKey) of +public_key_verify(Algorithm, Message, Signature, PublicKey) -> + case public_key:verify(Message, Algorithm, Signature, PublicKey) of true -> ok; false -> @@ -215,8 +246,8 @@ public_key_verify(Alg, Message, Signature, PublicKey) -> end. -hmac_verify(Alg, Message, HMAC, SecretKey) -> - case crypto:hmac(Alg, SecretKey, Message) of +hmac_verify(Algorithm, Message, HMAC, SecretKey) -> + case crypto:hmac(Algorithm, SecretKey, Message) of HMAC -> ok; _ -> @@ -443,4 +474,48 @@ rs256_test() -> ?assertMatch({ok, ExpectedPayload}, decode(EncodedToken, Checks, KS)). +encode_missing_alg_test() -> + ?assertEqual({error, missing_alg}, + encode({[]}, {[]}, <<"foo">>)). + + +encode_invalid_alg_test() -> + ?assertEqual({error, invalid_alg}, + encode({[{<<"alg">>, <<"BOGUS">>}]}, {[]}, <<"foo">>)). + + +encode_decode_test_() -> + [{Alg, encode_decode(Alg)} || Alg <- ?VALID_ALGS]. + + +encode_decode(Alg) -> + {EncodeKey, DecodeKey} = case verification_algorithm(Alg) of + {public_key, Algorithm} -> + jwtf_test_util:create_keypair(); + {hmac, Algorithm} -> + Key = <<"a-super-secret-key">>, + {Key, Key} + end, + Claims = claims(), + {ok, Encoded} = encode(header(Alg), Claims, EncodeKey), + KS = fun(_, _) -> DecodeKey end, + {ok, Decoded} = decode(Encoded, [], KS), + ?_assertMatch(Claims, Decoded). + + +header(Alg) -> + {[ + {<<"typ">>, <<"JWT">>}, + {<<"alg">>, Alg}, + {<<"kid">>, <<"20170520-00:00:00">>} + ]}. + + +claims() -> + EpochSeconds = 1496205841, + {[ + {<<"iat">>, EpochSeconds}, + {<<"exp">>, EpochSeconds + 3600} + ]}. + -endif. -- cgit v1.2.1 From 382229e7cb7fb36461d53fb1f858b674a6c2c193 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Fri, 9 Jun 2017 19:37:15 +0100 Subject: Ensure error reason is convertable to JSON --- src/jwtf.erl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index a461da98d..28cab6cd3 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -195,7 +195,7 @@ validate_nbf(Props, Checks) -> {true, undefined} -> throw({error, missing_nbf}); {true, IAT} -> - assert_past(nbf, IAT) + assert_past(<<"nbf">>, IAT) end. @@ -209,7 +209,7 @@ validate_exp(Props, Checks) -> {true, undefined} -> throw({error, missing_exp}); {true, EXP} -> - assert_future(exp, EXP) + assert_future(<<"exp">>, EXP) end. @@ -282,7 +282,7 @@ assert_past(Name, Time) -> true -> ok; false -> - throw({error, {Name, not_in_past}}) + throw({error, <>}) end. assert_future(Name, Time) -> @@ -290,7 +290,7 @@ assert_future(Name, Time) -> true -> ok; false -> - throw({error, {Name, not_in_future}}) + throw({error, <>}) end. @@ -373,7 +373,7 @@ missing_nbf_test() -> invalid_nbf_test() -> Encoded = encode(valid_header(), {[{<<"nbf">>, 32503680000}]}), - ?assertEqual({error, {nbf,not_in_past}}, decode(Encoded, [nbf], nil)). + ?assertEqual({error, <<"nbf not in past">>}, decode(Encoded, [nbf], nil)). missing_exp_test() -> @@ -383,7 +383,7 @@ missing_exp_test() -> invalid_exp_test() -> Encoded = encode(valid_header(), {[{<<"exp">>, 0}]}), - ?assertEqual({error, {exp,not_in_future}}, decode(Encoded, [exp], nil)). + ?assertEqual({error, <<"exp not in future">>}, decode(Encoded, [exp], nil)). missing_kid_test() -> -- cgit v1.2.1 From 768732af8209405738da6875c9474c0b0b99345b Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Thu, 15 Jun 2017 10:42:02 +0100 Subject: Return error from update_cache --- src/jwks.erl | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/jwks.erl b/src/jwks.erl index d6c44deb4..87fc4abdd 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -27,8 +27,12 @@ get_key(Url, Kty, Kid) -> {ok, Key} -> {ok, Key}; {error, not_found} -> - update_cache(Url), - lookup(Url, Kty, Kid) + case update_cache(Url) of + ok -> + lookup(Url, Kty, Kid); + {error, Reason} -> + {error, Reason} + end end. -- cgit v1.2.1 From a3b6661d50337ad50e065a660006ab7afd0125ea Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Thu, 15 Jun 2017 10:43:02 +0100 Subject: move error wrapping to decode function --- src/jwtf.erl | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index 28cab6cd3..ed0ce92f6 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -77,7 +77,7 @@ decode(EncodedToken, Checks, KS) -> {ok, decode_json(Payload)} catch throw:Error -> - Error + {error, Error} end. @@ -118,11 +118,11 @@ validate_typ(Props, Checks) -> {undefined, _} -> ok; {true, undefined} -> - throw({error, missing_typ}); + throw(missing_typ); {true, <<"JWT">>} -> ok; {true, _} -> - throw({error, invalid_typ}) + throw(invalid_typ) end. @@ -133,13 +133,13 @@ validate_alg(Props, Checks) -> {undefined, _} -> ok; {true, undefined} -> - throw({error, missing_alg}); + throw(missing_alg); {true, Alg} -> case lists:member(Alg, ?VALID_ALGS) of true -> ok; false -> - throw({error, invalid_alg}) + throw(invalid_alg) end end. @@ -161,11 +161,11 @@ validate_iss(Props, Checks) -> {undefined, _} -> ok; {_ISS, undefined} -> - throw({error, missing_iss}); + throw(missing_iss); {ISS, ISS} -> ok; {_, _} -> - throw({error, invalid_iss}) + throw(invalid_iss) end. @@ -177,11 +177,11 @@ validate_iat(Props, Checks) -> {undefined, _} -> ok; {true, undefined} -> - throw({error, missing_iat}); + throw(missing_iat); {true, IAT} when is_integer(IAT) -> ok; {true, _} -> - throw({error, invalid_iat}) + throw(invalid_iat) end. @@ -193,7 +193,7 @@ validate_nbf(Props, Checks) -> {undefined, _} -> ok; {true, undefined} -> - throw({error, missing_nbf}); + throw(missing_nbf); {true, IAT} -> assert_past(<<"nbf">>, IAT) end. @@ -207,7 +207,7 @@ validate_exp(Props, Checks) -> {undefined, _} -> ok; {true, undefined} -> - throw({error, missing_exp}); + throw(missing_exp); {true, EXP} -> assert_future(<<"exp">>, EXP) end. @@ -219,7 +219,7 @@ key(Props, Checks, KS) -> KID = prop(<<"kid">>, Props), case {Required, KID} of {true, undefined} -> - throw({error, missing_kid}); + throw(missing_kid); {_, KID} -> KS(Alg, KID) end. @@ -242,7 +242,7 @@ public_key_verify(Algorithm, Message, Signature, PublicKey) -> true -> ok; false -> - throw({error, bad_signature}) + throw(bad_signature) end. @@ -251,21 +251,21 @@ hmac_verify(Algorithm, Message, HMAC, SecretKey) -> HMAC -> ok; _ -> - throw({error, bad_hmac}) + throw(bad_hmac) end. split(EncodedToken) -> case binary:split(EncodedToken, <<$.>>, [global]) of [_, _, _] = Split -> Split; - _ -> throw({error, malformed_token}) + _ -> throw(malformed_token) end. decode_json(Encoded) -> case b64url:decode(Encoded) of {error, Reason} -> - throw({error, Reason}); + throw(Reason); Decoded -> jiffy:decode(Decoded) end. @@ -274,7 +274,7 @@ props({Props}) -> Props; props(_) -> - throw({error, not_object}). + throw(not_object). assert_past(Name, Time) -> @@ -282,7 +282,7 @@ assert_past(Name, Time) -> true -> ok; false -> - throw({error, <>}) + throw(<>) end. assert_future(Name, Time) -> @@ -290,7 +290,7 @@ assert_future(Name, Time) -> true -> ok; false -> - throw({error, <>}) + throw(<>) end. @@ -395,7 +395,7 @@ public_key_not_found_test() -> Encoded = encode( {[{<<"alg">>, <<"RS256">>}, {<<"kid">>, <<"1">>}]}, {[]}), - KS = fun(_, _) -> throw({error, not_found}) end, + KS = fun(_, _) -> throw(not_found) end, Expected = {error, not_found}, ?assertEqual(Expected, decode(Encoded, [], KS)). -- cgit v1.2.1 From f9c1f336974ae2d2b923065f92f35126ecb14313 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Thu, 15 Jun 2017 11:05:46 +0100 Subject: throw errors that chttpd:error_info can understand --- src/jwks.erl | 8 +++++-- src/jwtf.erl | 76 ++++++++++++++++++++++++++++++------------------------------ 2 files changed, 44 insertions(+), 40 deletions(-) diff --git a/src/jwks.erl b/src/jwks.erl index 87fc4abdd..4022e4184 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -61,8 +61,12 @@ get_keyset(Url) -> case ibrowse:send_req(Url, ReqHeaders, get) of {ok, "200", _RespHeaders, RespBody} -> {ok, parse_keyset(RespBody)}; - _Else -> - {error, get_keyset_failed} + {ok, Code, _RespHeaders, _RespBody} -> + couch_log:warning("get_keyset failed with code ~p", [Code]), + {error, {service_unavailable, <<"JWKS service unavailable">>}}; + {error, Reason} -> + couch_log:warning("get_keyset failed with reason ~p", [Reason]), + {error, {service_unavailable, <<"JWKS service unavailable">>}} end. diff --git a/src/jwtf.erl b/src/jwtf.erl index ed0ce92f6..bfecaccf4 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -44,7 +44,7 @@ encode(Header = {HeaderProps}, Claims, Key) -> try Alg = case prop(<<"alg">>, HeaderProps) of undefined -> - throw(missing_alg); + throw({bad_request, <<"Missing alg header parameter">>}); Val -> Val end, @@ -90,7 +90,7 @@ verification_algorithm(Alg) -> {Alg, Val} -> Val; false -> - throw(invalid_alg) + throw({bad_request, <<"Invalid alg header parameter">>}) end. @@ -118,11 +118,11 @@ validate_typ(Props, Checks) -> {undefined, _} -> ok; {true, undefined} -> - throw(missing_typ); + throw({bad_request, <<"Missing typ header parameter">>}); {true, <<"JWT">>} -> ok; {true, _} -> - throw(invalid_typ) + throw({bad_request, <<"Invalid typ header parameter">>}) end. @@ -133,13 +133,13 @@ validate_alg(Props, Checks) -> {undefined, _} -> ok; {true, undefined} -> - throw(missing_alg); + throw({bad_request, <<"Missing alg header parameter">>}); {true, Alg} -> case lists:member(Alg, ?VALID_ALGS) of true -> ok; false -> - throw(invalid_alg) + throw({bad_request, <<"Invalid alg header parameter">>}) end end. @@ -161,11 +161,11 @@ validate_iss(Props, Checks) -> {undefined, _} -> ok; {_ISS, undefined} -> - throw(missing_iss); + throw({bad_request, <<"Missing iss claim">>}); {ISS, ISS} -> ok; {_, _} -> - throw(invalid_iss) + throw({bad_request, <<"Invalid iss claim">>}) end. @@ -177,11 +177,11 @@ validate_iat(Props, Checks) -> {undefined, _} -> ok; {true, undefined} -> - throw(missing_iat); + throw({bad_request, <<"Missing iat claim">>}); {true, IAT} when is_integer(IAT) -> ok; {true, _} -> - throw(invalid_iat) + throw({bad_request, <<"Invalid iat claim">>}) end. @@ -193,7 +193,7 @@ validate_nbf(Props, Checks) -> {undefined, _} -> ok; {true, undefined} -> - throw(missing_nbf); + throw({bad_request, <<"Missing nbf claim">>}); {true, IAT} -> assert_past(<<"nbf">>, IAT) end. @@ -207,7 +207,7 @@ validate_exp(Props, Checks) -> {undefined, _} -> ok; {true, undefined} -> - throw(missing_exp); + throw({bad_request, <<"Missing exp claim">>}); {true, EXP} -> assert_future(<<"exp">>, EXP) end. @@ -219,7 +219,7 @@ key(Props, Checks, KS) -> KID = prop(<<"kid">>, Props), case {Required, KID} of {true, undefined} -> - throw(missing_kid); + throw({bad_request, <<"Missing kid claim">>}); {_, KID} -> KS(Alg, KID) end. @@ -242,7 +242,7 @@ public_key_verify(Algorithm, Message, Signature, PublicKey) -> true -> ok; false -> - throw(bad_signature) + throw({bad_request, <<"Bad signature">>}) end. @@ -251,21 +251,21 @@ hmac_verify(Algorithm, Message, HMAC, SecretKey) -> HMAC -> ok; _ -> - throw(bad_hmac) + throw({bad_request, <<"Bad HMAC">>}) end. split(EncodedToken) -> case binary:split(EncodedToken, <<$.>>, [global]) of [_, _, _] = Split -> Split; - _ -> throw(malformed_token) + _ -> throw({bad_request, <<"Malformed token">>}) end. decode_json(Encoded) -> case b64url:decode(Encoded) of {error, Reason} -> - throw(Reason); + throw({bad_request, Reason}); Decoded -> jiffy:decode(Decoded) end. @@ -274,7 +274,7 @@ props({Props}) -> Props; props(_) -> - throw(not_object). + throw({bad_request, <<"Not an object">>}). assert_past(Name, Time) -> @@ -282,7 +282,7 @@ assert_past(Name, Time) -> true -> ok; false -> - throw(<>) + throw({unauthorized, <>}) end. assert_future(Name, Time) -> @@ -290,7 +290,7 @@ assert_future(Name, Time) -> true -> ok; false -> - throw(<>) + throw({unauthorized, <>}) end. @@ -328,67 +328,67 @@ jwt_io_pubkey() -> missing_typ_test() -> Encoded = encode({[]}, []), - ?assertEqual({error, missing_typ}, decode(Encoded, [typ], nil)). + ?assertEqual({error, {bad_request,<<"Missing typ header parameter">>}}, decode(Encoded, [typ], nil)). invalid_typ_test() -> Encoded = encode({[{<<"typ">>, <<"NOPE">>}]}, []), - ?assertEqual({error, invalid_typ}, decode(Encoded, [typ], nil)). + ?assertEqual({error, {bad_request,<<"Invalid typ header parameter">>}}, decode(Encoded, [typ], nil)). missing_alg_test() -> Encoded = encode({[{<<"typ">>, <<"NOPE">>}]}, []), - ?assertEqual({error, missing_alg}, decode(Encoded, [alg], nil)). + ?assertEqual({error, {bad_request,<<"Missing alg header parameter">>}}, decode(Encoded, [alg], nil)). invalid_alg_test() -> Encoded = encode({[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"NOPE">>}]}, []), - ?assertEqual({error, invalid_alg}, decode(Encoded, [alg], nil)). + ?assertEqual({error, {bad_request,<<"Invalid alg header parameter">>}}, decode(Encoded, [alg], nil)). missing_iss_test() -> Encoded = encode(valid_header(), {[]}), - ?assertEqual({error, missing_iss}, decode(Encoded, [{iss, right}], nil)). + ?assertEqual({error, {bad_request,<<"Missing iss claim">>}}, decode(Encoded, [{iss, right}], nil)). invalid_iss_test() -> Encoded = encode(valid_header(), {[{<<"iss">>, <<"wrong">>}]}), - ?assertEqual({error, invalid_iss}, decode(Encoded, [{iss, right}], nil)). + ?assertEqual({error, {bad_request,<<"Invalid iss claim">>}}, decode(Encoded, [{iss, right}], nil)). missing_iat_test() -> Encoded = encode(valid_header(), {[]}), - ?assertEqual({error, missing_iat}, decode(Encoded, [iat], nil)). + ?assertEqual({error, {bad_request,<<"Missing iat claim">>}}, decode(Encoded, [iat], nil)). invalid_iat_test() -> Encoded = encode(valid_header(), {[{<<"iat">>, <<"hello">>}]}), - ?assertEqual({error, invalid_iat}, decode(Encoded, [iat], nil)). + ?assertEqual({error, {bad_request,<<"Invalid iat claim">>}}, decode(Encoded, [iat], nil)). missing_nbf_test() -> Encoded = encode(valid_header(), {[]}), - ?assertEqual({error, missing_nbf}, decode(Encoded, [nbf], nil)). + ?assertEqual({error, {bad_request,<<"Missing nbf claim">>}}, decode(Encoded, [nbf], nil)). invalid_nbf_test() -> Encoded = encode(valid_header(), {[{<<"nbf">>, 32503680000}]}), - ?assertEqual({error, <<"nbf not in past">>}, decode(Encoded, [nbf], nil)). + ?assertEqual({error, {unauthorized, <<"nbf not in past">>}}, decode(Encoded, [nbf], nil)). missing_exp_test() -> Encoded = encode(valid_header(), {[]}), - ?assertEqual({error, missing_exp}, decode(Encoded, [exp], nil)). + ?assertEqual({error, {bad_request, <<"Missing exp claim">>}}, decode(Encoded, [exp], nil)). invalid_exp_test() -> Encoded = encode(valid_header(), {[{<<"exp">>, 0}]}), - ?assertEqual({error, <<"exp not in future">>}, decode(Encoded, [exp], nil)). + ?assertEqual({error, {unauthorized, <<"exp not in future">>}}, decode(Encoded, [exp], nil)). missing_kid_test() -> Encoded = encode({[]}, {[]}), - ?assertEqual({error, missing_kid}, decode(Encoded, [kid], nil)). + ?assertEqual({error, {bad_request, <<"Missing kid claim">>}}, decode(Encoded, [kid], nil)). public_key_not_found_test() -> @@ -405,7 +405,7 @@ bad_rs256_sig_test() -> {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"RS256">>}]}, {[]}), KS = fun(<<"RS256">>, undefined) -> jwt_io_pubkey() end, - ?assertEqual({error, bad_signature}, decode(Encoded, [], KS)). + ?assertEqual({error, {bad_request, <<"Bad signature">>}}, decode(Encoded, [], KS)). bad_hs256_sig_test() -> @@ -413,11 +413,11 @@ bad_hs256_sig_test() -> {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"HS256">>}]}, {[]}), KS = fun(<<"HS256">>, undefined) -> <<"bad">> end, - ?assertEqual({error, bad_hmac}, decode(Encoded, [], KS)). + ?assertEqual({error, {bad_request, <<"Bad HMAC">>}}, decode(Encoded, [], KS)). malformed_token_test() -> - ?assertEqual({error, malformed_token}, decode(<<"a.b.c.d">>, [], nil)). + ?assertEqual({error, {bad_request, <<"Malformed token">>}}, decode(<<"a.b.c.d">>, [], nil)). %% jwt.io generated @@ -475,12 +475,12 @@ rs256_test() -> encode_missing_alg_test() -> - ?assertEqual({error, missing_alg}, + ?assertEqual({error, {bad_request, <<"Missing alg header parameter">>}}, encode({[]}, {[]}, <<"foo">>)). encode_invalid_alg_test() -> - ?assertEqual({error, invalid_alg}, + ?assertEqual({error, {bad_request, <<"Invalid alg header parameter">>}}, encode({[{<<"alg">>, <<"BOGUS">>}]}, {[]}, <<"foo">>)). -- cgit v1.2.1 From 8100be3d61ebf028d89a063c92de9a19816c64f9 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Thu, 15 Jun 2017 19:17:54 +0100 Subject: remove dependency on openssl commands --- src/jwtf.erl | 33 +++++++++++++++++++- src/jwtf_test_util.erl | 82 -------------------------------------------------- 2 files changed, 32 insertions(+), 83 deletions(-) delete mode 100644 src/jwtf_test_util.erl diff --git a/src/jwtf.erl b/src/jwtf.erl index bfecaccf4..809f3f391 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -305,6 +305,7 @@ prop(Prop, Props) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). +-include_lib("public_key/include/public_key.hrl"). encode(Header0, Payload0) -> Header1 = b64url:encode(jiffy:encode(Header0)), @@ -491,7 +492,7 @@ encode_decode_test_() -> encode_decode(Alg) -> {EncodeKey, DecodeKey} = case verification_algorithm(Alg) of {public_key, Algorithm} -> - jwtf_test_util:create_keypair(); + create_keypair(); {hmac, Algorithm} -> Key = <<"a-super-secret-key">>, {Key, Key} @@ -518,4 +519,34 @@ claims() -> {<<"exp">>, EpochSeconds + 3600} ]}. +create_keypair() -> + %% https://tools.ietf.org/html/rfc7517#appendix-C + N = decode(<<"t6Q8PWSi1dkJj9hTP8hNYFlvadM7DflW9mWepOJhJ66w7nyoK1gPNqFMSQRy" + "O125Gp-TEkodhWr0iujjHVx7BcV0llS4w5ACGgPrcAd6ZcSR0-Iqom-QFcNP" + "8Sjg086MwoqQU_LYywlAGZ21WSdS_PERyGFiNnj3QQlO8Yns5jCtLCRwLHL0" + "Pb1fEv45AuRIuUfVcPySBWYnDyGxvjYGDSM-AqWS9zIQ2ZilgT-GqUmipg0X" + "OC0Cc20rgLe2ymLHjpHciCKVAbY5-L32-lSeZO-Os6U15_aXrk9Gw8cPUaX1" + "_I8sLGuSiVdt3C_Fn2PZ3Z8i744FPFGGcG1qs2Wz-Q">>), + E = decode(<<"AQAB">>), + D = decode(<<"GRtbIQmhOZtyszfgKdg4u_N-R_mZGU_9k7JQ_jn1DnfTuMdSNprTeaSTyWfS" + "NkuaAwnOEbIQVy1IQbWVV25NY3ybc_IhUJtfri7bAXYEReWaCl3hdlPKXy9U" + "vqPYGR0kIXTQRqns-dVJ7jahlI7LyckrpTmrM8dWBo4_PMaenNnPiQgO0xnu" + "ToxutRZJfJvG4Ox4ka3GORQd9CsCZ2vsUDmsXOfUENOyMqADC6p1M3h33tsu" + "rY15k9qMSpG9OX_IJAXmxzAh_tWiZOwk2K4yxH9tS3Lq1yX8C1EWmeRDkK2a" + "hecG85-oLKQt5VEpWHKmjOi_gJSdSgqcN96X52esAQ">>), + RSAPrivateKey = #'RSAPrivateKey'{ + modulus = N, + publicExponent = E, + privateExponent = D + }, + RSAPublicKey = #'RSAPublicKey'{ + modulus = N, + publicExponent = E + }, + {RSAPrivateKey, RSAPublicKey}. + + +decode(Goop) -> + crypto:bytes_to_integer(b64url:decode(Goop)). + -endif. diff --git a/src/jwtf_test_util.erl b/src/jwtf_test_util.erl deleted file mode 100644 index c32ea1cb9..000000000 --- a/src/jwtf_test_util.erl +++ /dev/null @@ -1,82 +0,0 @@ -% Licensed under the Apache License, Version 2.0 (the "License"); you may not -% use this file except in compliance with the License. You may obtain a copy of -% the License at -% -% http://www.apache.org/licenses/LICENSE-2.0 -% -% Unless required by applicable law or agreed to in writing, software -% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -% License for the specific language governing permissions and limitations under -% the License. - --module(jwtf_test_util). - --export([ - create_private_key/0, - create_keypair/0, - to_public_key/1 -]). - --include_lib("public_key/include/public_key.hrl"). - --spec create_private_key() -> - #'RSAPrivateKey'{} | no_return(). -create_private_key() -> - create_private_key("/tmp"). - - --spec create_keypair() -> - {#'RSAPrivateKey'{}, #'RSAPublicKey'{}} | no_return(). -create_keypair() -> - PrivateKey = create_private_key(), - {PrivateKey, to_public_key(PrivateKey)}. - - --spec to_public_key(#'RSAPrivateKey'{}) -> - #'RSAPublicKey'{}. -to_public_key(#'RSAPrivateKey'{} = PrivateKey) -> - #'RSAPublicKey'{ - modulus = PrivateKey#'RSAPrivateKey'.modulus, - publicExponent = PrivateKey#'RSAPrivateKey'.publicExponent}. - - -create_private_key(TmpDir) -> - ok = verify_openssl(), - Path = filename:join(TmpDir, timestamp() ++ "-rsa.key.der"), - Bin = create_rsa_key(Path), - public_key:der_decode('RSAPrivateKey', Bin). - - -verify_openssl() -> - case os:cmd("openssl version") of - "OpenSSL 1." ++ _Rest -> - ok; - _ -> - throw({error, openssl_required}) - end. - - -timestamp() -> - lists:concat([integer_to_list(N) || N <- tuple_to_list(os:timestamp())]). - - -create_rsa_key(Path) -> - Cmd = "openssl genpkey -algorithm RSA -outform DER -out " ++ Path, - Out = os:cmd(Cmd), - %% Since os:cmd doesn't indicate if the command fails, we go to - %% some length to ensure the output looks correct. - ok = validate_genpkey_output(Out), - {ok, Bin} = file:read_file(Path), - ok = file:delete(Path), - Bin. - - -validate_genpkey_output(Out) when is_list(Out) -> - Length = length(Out), - case re:run(Out, "[.+\n]+") of % should only contain period, plus, or nl - {match, [{0, Length}]} -> - ok; - _ -> - throw({error, {openssl_genpkey_failed, Out}}) - end. -- cgit v1.2.1 From c6e58c4edf2747379f8c7627a7c30b26ed5493d4 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Fri, 16 Jun 2017 11:49:09 +0100 Subject: get_keyset needs ssl started --- src/jwks.erl | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jwks.erl b/src/jwks.erl index 4022e4184..458a4cf3e 100644 --- a/src/jwks.erl +++ b/src/jwks.erl @@ -117,6 +117,7 @@ decode_number(Base64) -> jwks_test() -> application:ensure_all_started(ibrowse), + application:ensure_all_started(ssl), ?assertMatch({ok, _}, get_keyset("https://iam.eu-gb.bluemix.net/oidc/keys")). rs_test() -> -- cgit v1.2.1 From 53c254f826d10c267f5c91cd519b4fdd3807b129 Mon Sep 17 00:00:00 2001 From: Jay Doane Date: Thu, 11 May 2017 16:53:47 -0700 Subject: Remove unnecessary props --- src/jwtf.erl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index 809f3f391..dcf83fb94 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -338,12 +338,12 @@ invalid_typ_test() -> missing_alg_test() -> - Encoded = encode({[{<<"typ">>, <<"NOPE">>}]}, []), + Encoded = encode({[]}, []), ?assertEqual({error, {bad_request,<<"Missing alg header parameter">>}}, decode(Encoded, [alg], nil)). invalid_alg_test() -> - Encoded = encode({[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"NOPE">>}]}, []), + Encoded = encode({[{<<"alg">>, <<"NOPE">>}]}, []), ?assertEqual({error, {bad_request,<<"Invalid alg header parameter">>}}, decode(Encoded, [alg], nil)). -- cgit v1.2.1 From a01cb0ff314dc62598190bacf315443a85e76510 Mon Sep 17 00:00:00 2001 From: Jay Doane Date: Thu, 11 May 2017 16:54:49 -0700 Subject: Make time explicitly in future --- src/jwtf.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index dcf83fb94..1f7a64266 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -373,7 +373,7 @@ missing_nbf_test() -> invalid_nbf_test() -> - Encoded = encode(valid_header(), {[{<<"nbf">>, 32503680000}]}), + Encoded = encode(valid_header(), {[{<<"nbf">>, 2 * now_seconds()}]}), ?assertEqual({error, {unauthorized, <<"nbf not in past">>}}, decode(Encoded, [nbf], nil)). -- cgit v1.2.1 From bb1744ea78b36059f9291921a77490774b2fdd55 Mon Sep 17 00:00:00 2001 From: Jay Doane Date: Tue, 20 Jun 2017 17:28:11 -0700 Subject: Suppress compiler warnings --- src/jwtf.erl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index 1f7a64266..3bf8be616 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -491,9 +491,9 @@ encode_decode_test_() -> encode_decode(Alg) -> {EncodeKey, DecodeKey} = case verification_algorithm(Alg) of - {public_key, Algorithm} -> + {public_key, _Algorithm} -> create_keypair(); - {hmac, Algorithm} -> + {hmac, _Algorithm} -> Key = <<"a-super-secret-key">>, {Key, Key} end, -- cgit v1.2.1 From 3d6c294eec8363575ac82c256a9a6b82d31d1673 Mon Sep 17 00:00:00 2001 From: Jay Doane Date: Mon, 7 Aug 2017 14:49:57 -0700 Subject: Move key cache to epep application --- src/jwks.erl | 162 ------------------------------------------------------- src/jwtf.app.src | 3 -- src/jwtf_app.erl | 26 --------- src/jwtf_sup.erl | 60 --------------------- 4 files changed, 251 deletions(-) delete mode 100644 src/jwks.erl delete mode 100644 src/jwtf_app.erl delete mode 100644 src/jwtf_sup.erl diff --git a/src/jwks.erl b/src/jwks.erl deleted file mode 100644 index 458a4cf3e..000000000 --- a/src/jwks.erl +++ /dev/null @@ -1,162 +0,0 @@ -% Licensed under the Apache License, Version 2.0 (the "License"); you may not -% use this file except in compliance with the License. You may obtain a copy of -% the License at -% -% http://www.apache.org/licenses/LICENSE-2.0 -% -% Unless required by applicable law or agreed to in writing, software -% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -% License for the specific language governing permissions and limitations under -% the License. - -% @doc -% This module fetches and parses JSON Web Key Sets (JWKS). - --module(jwks). - --export([ - get_key/3, - get_keyset/1 -]). - --include_lib("public_key/include/public_key.hrl"). - -get_key(Url, Kty, Kid) -> - case lookup(Url, Kty, Kid) of - {ok, Key} -> - {ok, Key}; - {error, not_found} -> - case update_cache(Url) of - ok -> - lookup(Url, Kty, Kid); - {error, Reason} -> - {error, Reason} - end - end. - - -lookup(Url, Kty, Kid) -> - case ets_lru:lookup_d(jwks_cache_lru, {Url, Kty, Kid}) of - {ok, Key} -> - {ok, Key}; - not_found -> - {error, not_found} - end. - - -update_cache(Url) -> - case get_keyset(Url) of - {ok, KeySet} -> - [ets_lru:insert(jwks_cache_lru, {Url, Kty, Kid}, Key) - || {{Kty, Kid}, Key} <- KeySet], - ok; - {error, Reason} -> - {error, Reason} - end. - - -get_keyset(Url) -> - ReqHeaders = [], - case ibrowse:send_req(Url, ReqHeaders, get) of - {ok, "200", _RespHeaders, RespBody} -> - {ok, parse_keyset(RespBody)}; - {ok, Code, _RespHeaders, _RespBody} -> - couch_log:warning("get_keyset failed with code ~p", [Code]), - {error, {service_unavailable, <<"JWKS service unavailable">>}}; - {error, Reason} -> - couch_log:warning("get_keyset failed with reason ~p", [Reason]), - {error, {service_unavailable, <<"JWKS service unavailable">>}} - end. - - -parse_keyset(Body) -> - {Props} = jiffy:decode(Body), - Keys = proplists:get_value(<<"keys">>, Props), - lists:flatmap(fun parse_key/1, Keys). - - -parse_key({Props}) -> - Alg = proplists:get_value(<<"alg">>, Props), - Kty = proplists:get_value(<<"kty">>, Props), - Kid = proplists:get_value(<<"kid">>, Props), - case {Alg, Kty} of - {<<"RS256">>, <<"RSA">>} -> - E = proplists:get_value(<<"e">>, Props), - N = proplists:get_value(<<"n">>, Props), - [{{Kty, Kid}, #'RSAPublicKey'{ - modulus = decode_number(N), - publicExponent = decode_number(E)}}]; - {<<"ES256">>, <<"EC">>} -> - Crv = proplists:get_value(<<"crv">>, Props), - case Crv of - <<"P-256">> -> - X = proplists:get_value(<<"x">>, Props), - Y = proplists:get_value(<<"y">>, Props), - Point = <<4:8, - (b64url:decode(X))/binary, - (b64url:decode(Y))/binary>>, - [{{Kty, Kid}, { - #'ECPoint'{point = Point}, - {namedCurve,{1,2,840,10045,3,1,7}} - }}]; - _ -> - [] - end; - _ -> - [] - end. - - -decode_number(Base64) -> - crypto:bytes_to_integer(b64url:decode(Base64)). - - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - -jwks_test() -> - application:ensure_all_started(ibrowse), - application:ensure_all_started(ssl), - ?assertMatch({ok, _}, get_keyset("https://iam.eu-gb.bluemix.net/oidc/keys")). - -rs_test() -> - Ejson = {[ - {<<"kty">>, <<"RSA">>}, - {<<"n">>, <<"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx" - "4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMs" - "tn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2" - "QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbI" - "SD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqb" - "w0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw">>}, - {<<"e">>, <<"AQAB">>}, - {<<"alg">>, <<"RS256">>}, - {<<"kid">>, <<"2011-04-29">>} - ]}, - ?assertMatch([{{<<"RSA">>, <<"2011-04-29">>}, {'RSAPublicKey', _, 65537}}], - parse_key(Ejson)). - - -ec_test() -> - PrivateKey = #'ECPrivateKey'{ - version = 1, - parameters = {namedCurve,{1,2,840,10045,3,1,7}}, - privateKey = b64url:decode("870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE"), - publicKey = <<4:8, - (b64url:decode("MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4"))/binary, - (b64url:decode("4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM"))/binary>>}, - Ejson = {[ - {<<"kty">>, <<"EC">>}, - {<<"crv">>, <<"P-256">>}, - {<<"x">>, <<"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4">>}, - {<<"y">>, <<"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM">>}, - {<<"alg">>, <<"ES256">>}, - {<<"kid">>, <<"1">>} - ]}, - ?assertMatch([{_Key, _Value}], parse_key(Ejson)), - [{_, ECPublicKey}] = parse_key(Ejson), - Msg = <<"foo">>, - Sig = public_key:sign(Msg, sha256, PrivateKey), - ?assert(public_key:verify(Msg, sha256, Sig, ECPublicKey)). - --endif. diff --git a/src/jwtf.app.src b/src/jwtf.app.src index 2ff221309..304bb9e0a 100644 --- a/src/jwtf.app.src +++ b/src/jwtf.app.src @@ -14,14 +14,11 @@ {description, "JSON Web Token Functions"}, {vsn, git}, {registered, []}, - {mod, { jwtf_app, []}}, {applications, [ kernel, stdlib, b64url, crypto, - ets_lru, - ibrowse, jiffy, public_key ]}, diff --git a/src/jwtf_app.erl b/src/jwtf_app.erl deleted file mode 100644 index 92a26d558..000000000 --- a/src/jwtf_app.erl +++ /dev/null @@ -1,26 +0,0 @@ -%%%------------------------------------------------------------------- -%% @doc jwtf public API -%% @end -%%%------------------------------------------------------------------- - --module(jwtf_app). - --behaviour(application). - -%% Application callbacks --export([start/2, stop/1]). - -%%==================================================================== -%% API -%%==================================================================== - -start(_StartType, _StartArgs) -> - jwtf_sup:start_link(). - -%%-------------------------------------------------------------------- -stop(_State) -> - ok. - -%%==================================================================== -%% Internal functions -%%==================================================================== diff --git a/src/jwtf_sup.erl b/src/jwtf_sup.erl deleted file mode 100644 index 7cf56e84f..000000000 --- a/src/jwtf_sup.erl +++ /dev/null @@ -1,60 +0,0 @@ -%%%------------------------------------------------------------------- -%% @doc epep top level supervisor. -%% @end -%%%------------------------------------------------------------------- - --module(jwtf_sup). - --behaviour(supervisor). - -%% API --export([start_link/0]). - -%% Supervisor callbacks --export([init/1]). - --define(SERVER, ?MODULE). - -%%==================================================================== -%% API functions -%%==================================================================== - -start_link() -> - supervisor:start_link({local, ?SERVER}, ?MODULE, []). - -%%==================================================================== -%% Supervisor callbacks -%%==================================================================== - -%% Child :: {Id,StartFunc,Restart,Shutdown,Type,Modules} -init([]) -> - Children = [ - {jwks_cache_lru, - {ets_lru, start_link, [jwks_cache_lru, lru_opts()]}, - permanent, 5000, worker, [ets_lru]} - ], - {ok, { {one_for_all, 5, 10}, Children} }. - -%%==================================================================== -%% Internal functions -%%==================================================================== - -lru_opts() -> - case config:get_integer("jwtf_cache", "max_objects", 50) of - MxObjs when MxObjs > 0 -> - [{max_objects, MxObjs}]; - _ -> - [] - end ++ - case config:get_integer("jwtf_cache", "max_size", 0) of - MxSize when MxSize > 0 -> - [{max_size, MxSize}]; - _ -> - [] - end ++ - case config:get_integer("jwtf_cache", "max_lifetime", 0) of - MxLT when MxLT > 0 -> - [{max_lifetime, MxLT}]; - _ -> - [] - end. -- cgit v1.2.1 From 8e937f2d5b67ad83fc1e8e5e7317c4ba53b43f36 Mon Sep 17 00:00:00 2001 From: Jay Doane Date: Fri, 11 Aug 2017 16:10:21 -0700 Subject: Separate tests into dedicated module Currently jwtf tests don't run in a continuous integration environment, presumably due to dependency rules. This splits the tests into their own module, but requires exposing a couple new functions in jwtf to support them. Some long lines were also broken into smaller lengths. --- src/jwtf.erl | 264 ++---------------------------------------------- test/jwtf_tests.erl | 281 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 253 deletions(-) create mode 100644 test/jwtf_tests.erl diff --git a/src/jwtf.erl b/src/jwtf.erl index 3bf8be616..c6cc78433 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -19,7 +19,9 @@ -export([ encode/3, - decode/3 + decode/3, + valid_algorithms/0, + verification_algorithm/1 ]). -define(ALGS, [ @@ -33,8 +35,6 @@ {<<"HS384">>, {hmac, sha384}}, {<<"HS512">>, {hmac, sha512}}]). --define(VALID_ALGS, proplists:get_keys(?ALGS)). - % @doc encode % Encode the JSON Header and Claims using Key and Alg obtained from Header @@ -81,6 +81,13 @@ decode(EncodedToken, Checks, KS) -> end. +% @doc valid_algorithms +% Return a list of supported algorithms +-spec valid_algorithms() -> [binary()]. +valid_algorithms() -> + proplists:get_keys(?ALGS). + + % @doc verification_algorithm % Return {VerificationMethod, Algorithm} tuple for the specified Alg -spec verification_algorithm(binary()) -> @@ -135,7 +142,7 @@ validate_alg(Props, Checks) -> {true, undefined} -> throw({bad_request, <<"Missing alg header parameter">>}); {true, Alg} -> - case lists:member(Alg, ?VALID_ALGS) of + case lists:member(Alg, valid_algorithms()) of true -> ok; false -> @@ -301,252 +308,3 @@ now_seconds() -> prop(Prop, Props) -> proplists:get_value(Prop, Props). - - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). --include_lib("public_key/include/public_key.hrl"). - -encode(Header0, Payload0) -> - Header1 = b64url:encode(jiffy:encode(Header0)), - Payload1 = b64url:encode(jiffy:encode(Payload0)), - Sig = b64url:encode(<<"bad">>), - <>. - -valid_header() -> - {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"RS256">>}]}. - -jwt_io_pubkey() -> - PublicKeyPEM = <<"-----BEGIN PUBLIC KEY-----\n" - "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGH" - "FHYLugdUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6" - "dvEOfou0/gCFQsHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkl" - "e+Q0pX/g6jXZ7r1/xAK5Do2kQ+X5xK9cipRgEKwIDAQAB\n" - "-----END PUBLIC KEY-----\n">>, - [PEMEntry] = public_key:pem_decode(PublicKeyPEM), - public_key:pem_entry_decode(PEMEntry). - - -missing_typ_test() -> - Encoded = encode({[]}, []), - ?assertEqual({error, {bad_request,<<"Missing typ header parameter">>}}, decode(Encoded, [typ], nil)). - - -invalid_typ_test() -> - Encoded = encode({[{<<"typ">>, <<"NOPE">>}]}, []), - ?assertEqual({error, {bad_request,<<"Invalid typ header parameter">>}}, decode(Encoded, [typ], nil)). - - -missing_alg_test() -> - Encoded = encode({[]}, []), - ?assertEqual({error, {bad_request,<<"Missing alg header parameter">>}}, decode(Encoded, [alg], nil)). - - -invalid_alg_test() -> - Encoded = encode({[{<<"alg">>, <<"NOPE">>}]}, []), - ?assertEqual({error, {bad_request,<<"Invalid alg header parameter">>}}, decode(Encoded, [alg], nil)). - - -missing_iss_test() -> - Encoded = encode(valid_header(), {[]}), - ?assertEqual({error, {bad_request,<<"Missing iss claim">>}}, decode(Encoded, [{iss, right}], nil)). - - -invalid_iss_test() -> - Encoded = encode(valid_header(), {[{<<"iss">>, <<"wrong">>}]}), - ?assertEqual({error, {bad_request,<<"Invalid iss claim">>}}, decode(Encoded, [{iss, right}], nil)). - - -missing_iat_test() -> - Encoded = encode(valid_header(), {[]}), - ?assertEqual({error, {bad_request,<<"Missing iat claim">>}}, decode(Encoded, [iat], nil)). - - -invalid_iat_test() -> - Encoded = encode(valid_header(), {[{<<"iat">>, <<"hello">>}]}), - ?assertEqual({error, {bad_request,<<"Invalid iat claim">>}}, decode(Encoded, [iat], nil)). - - -missing_nbf_test() -> - Encoded = encode(valid_header(), {[]}), - ?assertEqual({error, {bad_request,<<"Missing nbf claim">>}}, decode(Encoded, [nbf], nil)). - - -invalid_nbf_test() -> - Encoded = encode(valid_header(), {[{<<"nbf">>, 2 * now_seconds()}]}), - ?assertEqual({error, {unauthorized, <<"nbf not in past">>}}, decode(Encoded, [nbf], nil)). - - -missing_exp_test() -> - Encoded = encode(valid_header(), {[]}), - ?assertEqual({error, {bad_request, <<"Missing exp claim">>}}, decode(Encoded, [exp], nil)). - - -invalid_exp_test() -> - Encoded = encode(valid_header(), {[{<<"exp">>, 0}]}), - ?assertEqual({error, {unauthorized, <<"exp not in future">>}}, decode(Encoded, [exp], nil)). - - -missing_kid_test() -> - Encoded = encode({[]}, {[]}), - ?assertEqual({error, {bad_request, <<"Missing kid claim">>}}, decode(Encoded, [kid], nil)). - - -public_key_not_found_test() -> - Encoded = encode( - {[{<<"alg">>, <<"RS256">>}, {<<"kid">>, <<"1">>}]}, - {[]}), - KS = fun(_, _) -> throw(not_found) end, - Expected = {error, not_found}, - ?assertEqual(Expected, decode(Encoded, [], KS)). - - -bad_rs256_sig_test() -> - Encoded = encode( - {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"RS256">>}]}, - {[]}), - KS = fun(<<"RS256">>, undefined) -> jwt_io_pubkey() end, - ?assertEqual({error, {bad_request, <<"Bad signature">>}}, decode(Encoded, [], KS)). - - -bad_hs256_sig_test() -> - Encoded = encode( - {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"HS256">>}]}, - {[]}), - KS = fun(<<"HS256">>, undefined) -> <<"bad">> end, - ?assertEqual({error, {bad_request, <<"Bad HMAC">>}}, decode(Encoded, [], KS)). - - -malformed_token_test() -> - ?assertEqual({error, {bad_request, <<"Malformed token">>}}, decode(<<"a.b.c.d">>, [], nil)). - - -%% jwt.io generated -hs256_test() -> - EncodedToken = <<"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQ1Ni" - "J9.eyJpc3MiOiJodHRwczovL2Zvby5jb20iLCJpYXQiOjAsImV4cCI" - "6MTAwMDAwMDAwMDAwMDAsImtpZCI6ImJhciJ9.iS8AH11QHHlczkBn" - "Hl9X119BYLOZyZPllOVhSBZ4RZs">>, - KS = fun(<<"HS256">>, <<"123456">>) -> <<"secret">> end, - Checks = [{iss, <<"https://foo.com">>}, iat, exp, typ, alg, kid], - ?assertMatch({ok, _}, catch decode(EncodedToken, Checks, KS)). - - -%% pip install PyJWT -%% > import jwt -%% > jwt.encode({'foo':'bar'}, 'secret', algorithm='HS384') -hs384_test() -> - EncodedToken = <<"eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIif" - "Q.2quwghs6I56GM3j7ZQbn-ASZ53xdBqzPzTDHm_CtVec32LUy-Ezy" - "L3JjIe7WjL93">>, - KS = fun(<<"HS384">>, _) -> <<"secret">> end, - ?assertMatch({ok, {[{<<"foo">>,<<"bar">>}]}}, catch decode(EncodedToken, [], KS)). - - -%% pip install PyJWT -%% > import jwt -%% > jwt.encode({'foo':'bar'}, 'secret', algorithm='HS512') -hs512_test() -> - EncodedToken = <<"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYX" - "IifQ.WePl7achkd0oGNB8XRF_LJwxlyiPZqpdNgdKpDboAjSTsW" - "q-aOGNynTp8TOv8KjonFym8vwFwppXOLoLXbkIaQ">>, - KS = fun(<<"HS512">>, _) -> <<"secret">> end, - ?assertMatch({ok, {[{<<"foo">>,<<"bar">>}]}}, catch decode(EncodedToken, [], KS)). - - -%% jwt.io generated -rs256_test() -> - EncodedToken = <<"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0N" - "TY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.Ek" - "N-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8j" - "O19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHGuERTqYZyuhtF" - "39yxJPAjUESwxk2J5k_4zM3O-vtd1Ghyo4IbqKKSy6J9mTniYJPenn" - "5-HIirE">>, - - Checks = [sig, alg], - KS = fun(<<"RS256">>, undefined) -> jwt_io_pubkey() end, - - ExpectedPayload = {[ - {<<"sub">>, <<"1234567890">>}, - {<<"name">>, <<"John Doe">>}, - {<<"admin">>, true} - ]}, - - ?assertMatch({ok, ExpectedPayload}, decode(EncodedToken, Checks, KS)). - - -encode_missing_alg_test() -> - ?assertEqual({error, {bad_request, <<"Missing alg header parameter">>}}, - encode({[]}, {[]}, <<"foo">>)). - - -encode_invalid_alg_test() -> - ?assertEqual({error, {bad_request, <<"Invalid alg header parameter">>}}, - encode({[{<<"alg">>, <<"BOGUS">>}]}, {[]}, <<"foo">>)). - - -encode_decode_test_() -> - [{Alg, encode_decode(Alg)} || Alg <- ?VALID_ALGS]. - - -encode_decode(Alg) -> - {EncodeKey, DecodeKey} = case verification_algorithm(Alg) of - {public_key, _Algorithm} -> - create_keypair(); - {hmac, _Algorithm} -> - Key = <<"a-super-secret-key">>, - {Key, Key} - end, - Claims = claims(), - {ok, Encoded} = encode(header(Alg), Claims, EncodeKey), - KS = fun(_, _) -> DecodeKey end, - {ok, Decoded} = decode(Encoded, [], KS), - ?_assertMatch(Claims, Decoded). - - -header(Alg) -> - {[ - {<<"typ">>, <<"JWT">>}, - {<<"alg">>, Alg}, - {<<"kid">>, <<"20170520-00:00:00">>} - ]}. - - -claims() -> - EpochSeconds = 1496205841, - {[ - {<<"iat">>, EpochSeconds}, - {<<"exp">>, EpochSeconds + 3600} - ]}. - -create_keypair() -> - %% https://tools.ietf.org/html/rfc7517#appendix-C - N = decode(<<"t6Q8PWSi1dkJj9hTP8hNYFlvadM7DflW9mWepOJhJ66w7nyoK1gPNqFMSQRy" - "O125Gp-TEkodhWr0iujjHVx7BcV0llS4w5ACGgPrcAd6ZcSR0-Iqom-QFcNP" - "8Sjg086MwoqQU_LYywlAGZ21WSdS_PERyGFiNnj3QQlO8Yns5jCtLCRwLHL0" - "Pb1fEv45AuRIuUfVcPySBWYnDyGxvjYGDSM-AqWS9zIQ2ZilgT-GqUmipg0X" - "OC0Cc20rgLe2ymLHjpHciCKVAbY5-L32-lSeZO-Os6U15_aXrk9Gw8cPUaX1" - "_I8sLGuSiVdt3C_Fn2PZ3Z8i744FPFGGcG1qs2Wz-Q">>), - E = decode(<<"AQAB">>), - D = decode(<<"GRtbIQmhOZtyszfgKdg4u_N-R_mZGU_9k7JQ_jn1DnfTuMdSNprTeaSTyWfS" - "NkuaAwnOEbIQVy1IQbWVV25NY3ybc_IhUJtfri7bAXYEReWaCl3hdlPKXy9U" - "vqPYGR0kIXTQRqns-dVJ7jahlI7LyckrpTmrM8dWBo4_PMaenNnPiQgO0xnu" - "ToxutRZJfJvG4Ox4ka3GORQd9CsCZ2vsUDmsXOfUENOyMqADC6p1M3h33tsu" - "rY15k9qMSpG9OX_IJAXmxzAh_tWiZOwk2K4yxH9tS3Lq1yX8C1EWmeRDkK2a" - "hecG85-oLKQt5VEpWHKmjOi_gJSdSgqcN96X52esAQ">>), - RSAPrivateKey = #'RSAPrivateKey'{ - modulus = N, - publicExponent = E, - privateExponent = D - }, - RSAPublicKey = #'RSAPublicKey'{ - modulus = N, - publicExponent = E - }, - {RSAPrivateKey, RSAPublicKey}. - - -decode(Goop) -> - crypto:bytes_to_integer(b64url:decode(Goop)). - --endif. diff --git a/test/jwtf_tests.erl b/test/jwtf_tests.erl new file mode 100644 index 000000000..527bc327f --- /dev/null +++ b/test/jwtf_tests.erl @@ -0,0 +1,281 @@ +% Licensed under the Apache License, Version 2.0 (the "License"); you may not +% use this file except in compliance with the License. You may obtain a copy of +% the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +% License for the specific language governing permissions and limitations under +% the License. + +-module(jwtf_tests). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("public_key/include/public_key.hrl"). + +encode(Header0, Payload0) -> + Header1 = b64url:encode(jiffy:encode(Header0)), + Payload1 = b64url:encode(jiffy:encode(Payload0)), + Sig = b64url:encode(<<"bad">>), + <>. + +valid_header() -> + {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"RS256">>}]}. + +jwt_io_pubkey() -> + PublicKeyPEM = <<"-----BEGIN PUBLIC KEY-----\n" + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGH" + "FHYLugdUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6" + "dvEOfou0/gCFQsHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkl" + "e+Q0pX/g6jXZ7r1/xAK5Do2kQ+X5xK9cipRgEKwIDAQAB\n" + "-----END PUBLIC KEY-----\n">>, + [PEMEntry] = public_key:pem_decode(PublicKeyPEM), + public_key:pem_entry_decode(PEMEntry). + + +missing_typ_test() -> + Encoded = encode({[]}, []), + ?assertEqual({error, {bad_request,<<"Missing typ header parameter">>}}, + jwtf:decode(Encoded, [typ], nil)). + + +invalid_typ_test() -> + Encoded = encode({[{<<"typ">>, <<"NOPE">>}]}, []), + ?assertEqual({error, {bad_request,<<"Invalid typ header parameter">>}}, + jwtf:decode(Encoded, [typ], nil)). + + +missing_alg_test() -> + Encoded = encode({[]}, []), + ?assertEqual({error, {bad_request,<<"Missing alg header parameter">>}}, + jwtf:decode(Encoded, [alg], nil)). + + +invalid_alg_test() -> + Encoded = encode({[{<<"alg">>, <<"NOPE">>}]}, []), + ?assertEqual({error, {bad_request,<<"Invalid alg header parameter">>}}, + jwtf:decode(Encoded, [alg], nil)). + + +missing_iss_test() -> + Encoded = encode(valid_header(), {[]}), + ?assertEqual({error, {bad_request,<<"Missing iss claim">>}}, + jwtf:decode(Encoded, [{iss, right}], nil)). + + +invalid_iss_test() -> + Encoded = encode(valid_header(), {[{<<"iss">>, <<"wrong">>}]}), + ?assertEqual({error, {bad_request,<<"Invalid iss claim">>}}, + jwtf:decode(Encoded, [{iss, right}], nil)). + + +missing_iat_test() -> + Encoded = encode(valid_header(), {[]}), + ?assertEqual({error, {bad_request,<<"Missing iat claim">>}}, + jwtf:decode(Encoded, [iat], nil)). + + +invalid_iat_test() -> + Encoded = encode(valid_header(), {[{<<"iat">>, <<"hello">>}]}), + ?assertEqual({error, {bad_request,<<"Invalid iat claim">>}}, + jwtf:decode(Encoded, [iat], nil)). + + +missing_nbf_test() -> + Encoded = encode(valid_header(), {[]}), + ?assertEqual({error, {bad_request,<<"Missing nbf claim">>}}, + jwtf:decode(Encoded, [nbf], nil)). + + +invalid_nbf_test() -> + Encoded = encode(valid_header(), {[{<<"nbf">>, 2 * now_seconds()}]}), + ?assertEqual({error, {unauthorized, <<"nbf not in past">>}}, + jwtf:decode(Encoded, [nbf], nil)). + + +missing_exp_test() -> + Encoded = encode(valid_header(), {[]}), + ?assertEqual({error, {bad_request, <<"Missing exp claim">>}}, + jwtf:decode(Encoded, [exp], nil)). + + +invalid_exp_test() -> + Encoded = encode(valid_header(), {[{<<"exp">>, 0}]}), + ?assertEqual({error, {unauthorized, <<"exp not in future">>}}, + jwtf:decode(Encoded, [exp], nil)). + + +missing_kid_test() -> + Encoded = encode({[]}, {[]}), + ?assertEqual({error, {bad_request, <<"Missing kid claim">>}}, + jwtf:decode(Encoded, [kid], nil)). + + +public_key_not_found_test() -> + Encoded = encode( + {[{<<"alg">>, <<"RS256">>}, {<<"kid">>, <<"1">>}]}, + {[]}), + KS = fun(_, _) -> throw(not_found) end, + Expected = {error, not_found}, + ?assertEqual(Expected, jwtf:decode(Encoded, [], KS)). + + +bad_rs256_sig_test() -> + Encoded = encode( + {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"RS256">>}]}, + {[]}), + KS = fun(<<"RS256">>, undefined) -> jwt_io_pubkey() end, + ?assertEqual({error, {bad_request, <<"Bad signature">>}}, + jwtf:decode(Encoded, [], KS)). + + +bad_hs256_sig_test() -> + Encoded = encode( + {[{<<"typ">>, <<"JWT">>}, {<<"alg">>, <<"HS256">>}]}, + {[]}), + KS = fun(<<"HS256">>, undefined) -> <<"bad">> end, + ?assertEqual({error, {bad_request, <<"Bad HMAC">>}}, + jwtf:decode(Encoded, [], KS)). + + +malformed_token_test() -> + ?assertEqual({error, {bad_request, <<"Malformed token">>}}, + jwtf:decode(<<"a.b.c.d">>, [], nil)). + + +%% jwt.io generated +hs256_test() -> + EncodedToken = <<"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQ1Ni" + "J9.eyJpc3MiOiJodHRwczovL2Zvby5jb20iLCJpYXQiOjAsImV4cCI" + "6MTAwMDAwMDAwMDAwMDAsImtpZCI6ImJhciJ9.iS8AH11QHHlczkBn" + "Hl9X119BYLOZyZPllOVhSBZ4RZs">>, + KS = fun(<<"HS256">>, <<"123456">>) -> <<"secret">> end, + Checks = [{iss, <<"https://foo.com">>}, iat, exp, typ, alg, kid], + ?assertMatch({ok, _}, catch jwtf:decode(EncodedToken, Checks, KS)). + + +%% pip install PyJWT +%% > import jwt +%% > jwt.encode({'foo':'bar'}, 'secret', algorithm='HS384') +hs384_test() -> + EncodedToken = <<"eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIif" + "Q.2quwghs6I56GM3j7ZQbn-ASZ53xdBqzPzTDHm_CtVec32LUy-Ezy" + "L3JjIe7WjL93">>, + KS = fun(<<"HS384">>, _) -> <<"secret">> end, + ?assertMatch({ok, {[{<<"foo">>,<<"bar">>}]}}, + catch jwtf:decode(EncodedToken, [], KS)). + + +%% pip install PyJWT +%% > import jwt +%% > jwt.encode({'foo':'bar'}, 'secret', algorithm='HS512') +hs512_test() -> + EncodedToken = <<"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYX" + "IifQ.WePl7achkd0oGNB8XRF_LJwxlyiPZqpdNgdKpDboAjSTsW" + "q-aOGNynTp8TOv8KjonFym8vwFwppXOLoLXbkIaQ">>, + KS = fun(<<"HS512">>, _) -> <<"secret">> end, + ?assertMatch({ok, {[{<<"foo">>,<<"bar">>}]}}, + catch jwtf:decode(EncodedToken, [], KS)). + + +%% jwt.io generated +rs256_test() -> + EncodedToken = <<"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0N" + "TY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.Ek" + "N-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8j" + "O19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHGuERTqYZyuhtF" + "39yxJPAjUESwxk2J5k_4zM3O-vtd1Ghyo4IbqKKSy6J9mTniYJPenn" + "5-HIirE">>, + + Checks = [sig, alg], + KS = fun(<<"RS256">>, undefined) -> jwt_io_pubkey() end, + + ExpectedPayload = {[ + {<<"sub">>, <<"1234567890">>}, + {<<"name">>, <<"John Doe">>}, + {<<"admin">>, true} + ]}, + + ?assertMatch({ok, ExpectedPayload}, jwtf:decode(EncodedToken, Checks, KS)). + + +encode_missing_alg_test() -> + ?assertEqual({error, {bad_request, <<"Missing alg header parameter">>}}, + jwtf:encode({[]}, {[]}, <<"foo">>)). + + +encode_invalid_alg_test() -> + ?assertEqual({error, {bad_request, <<"Invalid alg header parameter">>}}, + jwtf:encode({[{<<"alg">>, <<"BOGUS">>}]}, {[]}, <<"foo">>)). + + +encode_decode_test_() -> + [{Alg, encode_decode(Alg)} || Alg <- jwtf:valid_algorithms()]. + + +encode_decode(Alg) -> + {EncodeKey, DecodeKey} = case jwtf:verification_algorithm(Alg) of + {public_key, _Algorithm} -> + create_keypair(); + {hmac, _Algorithm} -> + Key = <<"a-super-secret-key">>, + {Key, Key} + end, + Claims = claims(), + {ok, Encoded} = jwtf:encode(header(Alg), Claims, EncodeKey), + KS = fun(_, _) -> DecodeKey end, + {ok, Decoded} = jwtf:decode(Encoded, [], KS), + ?_assertMatch(Claims, Decoded). + + +header(Alg) -> + {[ + {<<"typ">>, <<"JWT">>}, + {<<"alg">>, Alg}, + {<<"kid">>, <<"20170520-00:00:00">>} + ]}. + + +claims() -> + EpochSeconds = 1496205841, + {[ + {<<"iat">>, EpochSeconds}, + {<<"exp">>, EpochSeconds + 3600} + ]}. + +create_keypair() -> + %% https://tools.ietf.org/html/rfc7517#appendix-C + N = decode(<<"t6Q8PWSi1dkJj9hTP8hNYFlvadM7DflW9mWepOJhJ66w7nyoK1gPNqFMSQRy" + "O125Gp-TEkodhWr0iujjHVx7BcV0llS4w5ACGgPrcAd6ZcSR0-Iqom-QFcNP" + "8Sjg086MwoqQU_LYywlAGZ21WSdS_PERyGFiNnj3QQlO8Yns5jCtLCRwLHL0" + "Pb1fEv45AuRIuUfVcPySBWYnDyGxvjYGDSM-AqWS9zIQ2ZilgT-GqUmipg0X" + "OC0Cc20rgLe2ymLHjpHciCKVAbY5-L32-lSeZO-Os6U15_aXrk9Gw8cPUaX1" + "_I8sLGuSiVdt3C_Fn2PZ3Z8i744FPFGGcG1qs2Wz-Q">>), + E = decode(<<"AQAB">>), + D = decode(<<"GRtbIQmhOZtyszfgKdg4u_N-R_mZGU_9k7JQ_jn1DnfTuMdSNprTeaSTyWfS" + "NkuaAwnOEbIQVy1IQbWVV25NY3ybc_IhUJtfri7bAXYEReWaCl3hdlPKXy9U" + "vqPYGR0kIXTQRqns-dVJ7jahlI7LyckrpTmrM8dWBo4_PMaenNnPiQgO0xnu" + "ToxutRZJfJvG4Ox4ka3GORQd9CsCZ2vsUDmsXOfUENOyMqADC6p1M3h33tsu" + "rY15k9qMSpG9OX_IJAXmxzAh_tWiZOwk2K4yxH9tS3Lq1yX8C1EWmeRDkK2a" + "hecG85-oLKQt5VEpWHKmjOi_gJSdSgqcN96X52esAQ">>), + RSAPrivateKey = #'RSAPrivateKey'{ + modulus = N, + publicExponent = E, + privateExponent = D + }, + RSAPublicKey = #'RSAPublicKey'{ + modulus = N, + publicExponent = E + }, + {RSAPrivateKey, RSAPublicKey}. + + +decode(Goop) -> + crypto:bytes_to_integer(b64url:decode(Goop)). + + +now_seconds() -> + {MegaSecs, Secs, _MicroSecs} = os:timestamp(), + MegaSecs * 1000000 + Secs. -- cgit v1.2.1 From 640e39caa7ec4124d75b1cb9132bf774b6edef86 Mon Sep 17 00:00:00 2001 From: Robert Newson Date: Tue, 10 Mar 2020 16:22:38 +0000 Subject: Create LICENSE --- LICENSE | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..d9a10c0d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS -- cgit v1.2.1 From b00814e58ba601a83b676c336ce2f5d82744a535 Mon Sep 17 00:00:00 2001 From: Jay Doane Date: Wed, 11 Mar 2020 01:36:59 -0700 Subject: Enable code coverage --- rebar.config | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 rebar.config diff --git a/rebar.config b/rebar.config new file mode 100644 index 000000000..e0d18443b --- /dev/null +++ b/rebar.config @@ -0,0 +1,2 @@ +{cover_enabled, true}. +{cover_print_enabled, true}. -- cgit v1.2.1 From 1794e146c8b3283c77fb549f75afbc96a92d62be Mon Sep 17 00:00:00 2001 From: Jay Doane Date: Wed, 11 Mar 2020 11:58:56 -0700 Subject: Handle malformed tokens with jiffy 1.x Recent changes in how `jiffy:decode/1` handles malformed JSON has caused `jwtf:decode/3` to fail to properly return a bad request 400 response for some malformed tokens. First, this changes the name of the function to `decode_b64url_json/1`, indicating that it decodes something that has been first been JSON encoded, and then base64url encoded. More substantially, it wraps both the base64url and jiffy decoding in a try/catch block, since both can throw errors, while the former can also return an error tuple. Tests have been added to ensure all code paths are covered. --- src/jwtf.erl | 24 +++++++++++++++--------- test/jwtf_tests.erl | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/jwtf.erl b/src/jwtf.erl index c6cc78433..8e58e0897 100644 --- a/src/jwtf.erl +++ b/src/jwtf.erl @@ -74,7 +74,7 @@ decode(EncodedToken, Checks, KS) -> try [Header, Payload, Signature] = split(EncodedToken), validate(Header, Payload, Signature, Checks, KS), - {ok, decode_json(Payload)} + {ok, decode_b64url_json(Payload)} catch throw:Error -> {error, Error} @@ -102,10 +102,10 @@ verification_algorithm(Alg) -> validate(Header0, Payload0, Signature, Checks, KS) -> - Header1 = props(decode_json(Header0)), + Header1 = props(decode_b64url_json(Header0)), validate_header(Header1, Checks), - Payload1 = props(decode_json(Payload0)), + Payload1 = props(decode_b64url_json(Payload0)), validate_payload(Payload1, Checks), Alg = prop(<<"alg">>, Header1), @@ -269,14 +269,20 @@ split(EncodedToken) -> end. -decode_json(Encoded) -> - case b64url:decode(Encoded) of - {error, Reason} -> - throw({bad_request, Reason}); - Decoded -> - jiffy:decode(Decoded) +decode_b64url_json(B64UrlEncoded) -> + try + case b64url:decode(B64UrlEncoded) of + {error, Reason} -> + throw({bad_request, Reason}); + JsonEncoded -> + jiffy:decode(JsonEncoded) + end + catch + error:Error -> + throw({bad_request, Error}) end. + props({Props}) -> Props; diff --git a/test/jwtf_tests.erl b/test/jwtf_tests.erl index 527bc327f..dcebe5f40 100644 --- a/test/jwtf_tests.erl +++ b/test/jwtf_tests.erl @@ -35,6 +35,30 @@ jwt_io_pubkey() -> public_key:pem_entry_decode(PEMEntry). +b64_badarg_test() -> + Encoded = <<"0.0.0">>, + ?assertEqual({error, {bad_request,badarg}}, + jwtf:decode(Encoded, [], nil)). + + +b64_bad_block_test() -> + Encoded = <<" aGVsbG8. aGVsbG8. aGVsbG8">>, + ?assertEqual({error, {bad_request,{bad_block,0}}}, + jwtf:decode(Encoded, [], nil)). + + +invalid_json_test() -> + Encoded = <<"fQ.fQ.fQ">>, + ?assertEqual({error, {bad_request,{1,invalid_json}}}, + jwtf:decode(Encoded, [], nil)). + + +truncated_json_test() -> + Encoded = <<"ew.ew.ew">>, + ?assertEqual({error, {bad_request,{2,truncated_json}}}, + jwtf:decode(Encoded, [], nil)). + + missing_typ_test() -> Encoded = encode({[]}, []), ?assertEqual({error, {bad_request,<<"Missing typ header parameter">>}}, -- cgit v1.2.1