summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRobert Newson <rnewson@apache.org>2020-04-02 18:57:12 +0100
committerGitHub <noreply@github.com>2020-04-02 18:57:12 +0100
commit99692f4bb9a10589f082750f42719f6533d1ae56 (patch)
tree95aa563c3146e02d5b827c4849575621e6214905
parent70fa5efbb85a8d404efb6a74b72a84fdcfffc9aa (diff)
parent2d70e73b420ac23e25efdca57de70ff00cb9559b (diff)
downloadcouchdb-99692f4bb9a10589f082750f42719f6533d1ae56.tar.gz
Merge pull request #2742 from apache/backport-jwt-3.x
Backport jwt 3.x
-rw-r--r--mix.exs2
-rw-r--r--rebar.config.script1
-rw-r--r--rel/overlay/etc/default.ini21
-rw-r--r--rel/reltool.config2
-rw-r--r--src/chttpd/src/chttpd_auth.erl4
-rw-r--r--src/couch/src/couch_httpd_auth.erl29
-rw-r--r--src/jwtf/.gitignore4
-rw-r--r--src/jwtf/LICENSE176
-rw-r--r--src/jwtf/README.md18
-rw-r--r--src/jwtf/rebar.config2
-rw-r--r--src/jwtf/src/jwtf.app.src32
-rw-r--r--src/jwtf/src/jwtf.erl353
-rw-r--r--src/jwtf/src/jwtf_app.erl28
-rw-r--r--src/jwtf/src/jwtf_keystore.erl161
-rw-r--r--src/jwtf/src/jwtf_sup.erl38
-rw-r--r--src/jwtf/test/jwtf_keystore_tests.erl57
-rw-r--r--src/jwtf/test/jwtf_tests.erl317
-rw-r--r--test/elixir/test/config/test-config.ini2
-rw-r--r--test/elixir/test/jwtauth_test.exs139
19 files changed, 1385 insertions, 1 deletions
diff --git a/mix.exs b/mix.exs
index d717e4b4a..c74dce865 100644
--- a/mix.exs
+++ b/mix.exs
@@ -65,7 +65,9 @@ defmodule CouchDBTest.Mixfile do
{:junit_formatter, "~> 3.0", only: [:dev, :test, :integration]},
{:httpotion, ">= 3.1.3", only: [:dev, :test, :integration], runtime: false},
{:excoveralls, "~> 0.12", only: :test},
+ {:b64url, path: Path.expand("src/b64url", __DIR__)},
{:jiffy, path: Path.expand("src/jiffy", __DIR__)},
+ {:jwtf, path: Path.expand("src/jwtf", __DIR__)},
{:ibrowse,
path: Path.expand("src/ibrowse", __DIR__), override: true, compile: false},
{:credo, "~> 1.0.0", only: [:dev, :test, :integration], runtime: false}
diff --git a/rebar.config.script b/rebar.config.script
index 1dcad566c..408ad3d48 100644
--- a/rebar.config.script
+++ b/rebar.config.script
@@ -132,6 +132,7 @@ SubDirs = [
"src/fabric",
"src/global_changes",
"src/ioq",
+ "src/jwtf",
"src/ken",
"src/mango",
"src/rexi",
diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index d64b88f32..4654d55ee 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -134,10 +134,31 @@ max_db_number_for_dbs_info_req = 100
; authentication_handlers = {chttpd_auth, cookie_authentication_handler}, {chttpd_auth, default_authentication_handler}
; uncomment the next line to enable proxy authentication
; authentication_handlers = {chttpd_auth, proxy_authentication_handler}, {chttpd_auth, cookie_authentication_handler}, {chttpd_auth, default_authentication_handler}
+; uncomment the next line to enable JWT authentication
+; authentication_handlers = {chttpd_auth, jwt_authentication_handler}, {chttpd_auth, cookie_authentication_handler}, {chttpd_auth, default_authentication_handler}
; prevent non-admins from accessing /_all_dbs
; admin_only_all_dbs = true
+;[jwt_auth]
+; List of claims to validate
+; required_claims =
+;
+; [jwt_keys]
+; Configure at least one key here if using the JWT auth handler.
+; If your JWT tokens do not include a "kid" attribute, use "_default"
+; as the config key, otherwise use the kid as the config key.
+; Examples
+; hmac:_default = aGVsbG8=
+; hmac:foo = aGVsbG8=
+; The config values can represent symmetric and asymmetrics keys.
+; For symmetrics keys, the value is base64 encoded;
+; hmac:_default = aGVsbG8= # base64-encoded form of "hello"
+; For asymmetric keys, the value is the PEM encoding of the public
+; key with newlines replaced with the escape sequence \n.
+; rsa:foo = -----BEGIN PUBLIC KEY-----\nMIIBIjAN...IDAQAB\n-----END PUBLIC KEY-----\n
+; ec:bar = -----BEGIN PUBLIC KEY-----\nMHYwEAYHK...AzztRs\n-----END PUBLIC KEY-----\n
+
[couch_peruser]
; If enabled, couch_peruser ensures that a private per-user database
; exists for each document in _users. These databases are writable only
diff --git a/rel/reltool.config b/rel/reltool.config
index 5285504ba..796019298 100644
--- a/rel/reltool.config
+++ b/rel/reltool.config
@@ -51,6 +51,7 @@
ibrowse,
ioq,
jiffy,
+ jwtf,
ken,
khash,
mango,
@@ -110,6 +111,7 @@
{app, ibrowse, [{incl_cond, include}]},
{app, ioq, [{incl_cond, include}]},
{app, jiffy, [{incl_cond, include}]},
+ {app, jwtf, [{incl_cond, include}]},
{app, ken, [{incl_cond, include}]},
{app, khash, [{incl_cond, include}]},
{app, mango, [{incl_cond, include}]},
diff --git a/src/chttpd/src/chttpd_auth.erl b/src/chttpd/src/chttpd_auth.erl
index cf376eefe..ffae78171 100644
--- a/src/chttpd/src/chttpd_auth.erl
+++ b/src/chttpd/src/chttpd_auth.erl
@@ -18,6 +18,7 @@
-export([default_authentication_handler/1]).
-export([cookie_authentication_handler/1]).
-export([proxy_authentication_handler/1]).
+-export([jwt_authentication_handler/1]).
-export([party_mode_handler/1]).
-export([handle_session_req/1]).
@@ -51,6 +52,9 @@ cookie_authentication_handler(Req) ->
proxy_authentication_handler(Req) ->
couch_httpd_auth:proxy_authentication_handler(Req).
+jwt_authentication_handler(Req) ->
+ couch_httpd_auth:jwt_authentication_handler(Req).
+
party_mode_handler(#httpd{method='POST', path_parts=[<<"_session">>]} = Req) ->
% See #1947 - users should always be able to attempt a login
Req#httpd{user_ctx=#user_ctx{}};
diff --git a/src/couch/src/couch_httpd_auth.erl b/src/couch/src/couch_httpd_auth.erl
index 5e4450301..b7abf2a01 100644
--- a/src/couch/src/couch_httpd_auth.erl
+++ b/src/couch/src/couch_httpd_auth.erl
@@ -31,6 +31,8 @@
-export([cookie_auth_cookie/4, cookie_scheme/1]).
-export([maybe_value/3]).
+-export([jwt_authentication_handler/1]).
+
-import(couch_httpd, [header_value/2, send_json/2,send_json/4, send_method_not_allowed/2]).
-compile({no_auto_import,[integer_to_binary/1, integer_to_binary/2]}).
@@ -186,6 +188,33 @@ proxy_auth_user(Req) ->
end
end.
+jwt_authentication_handler(Req) ->
+ case header_value(Req, "Authorization") of
+ "Bearer " ++ Jwt ->
+ RequiredClaims = get_configured_claims(),
+ case jwtf:decode(?l2b(Jwt), [alg | RequiredClaims], fun jwtf_keystore:get/2) of
+ {ok, {Claims}} ->
+ case lists:keyfind(<<"sub">>, 1, Claims) of
+ false -> throw({unauthorized, <<"Token missing sub claim.">>});
+ {_, User} -> Req#httpd{user_ctx=#user_ctx{
+ name = User,
+ roles = couch_util:get_value(<<"_couchdb.roles">>, Claims, [])
+ }}
+ end;
+ {error, Reason} ->
+ throw(Reason)
+ end;
+ _ -> Req
+ end.
+
+get_configured_claims() ->
+ Claims = config:get("jwt_auth", "required_claims", ""),
+ case re:split(Claims, "\s*,\s*", [{return, list}]) of
+ [[]] ->
+ []; %% if required_claims is the empty string.
+ List ->
+ [list_to_existing_atom(C) || C <- List]
+ end.
cookie_authentication_handler(Req) ->
cookie_authentication_handler(Req, couch_auth_cache).
diff --git a/src/jwtf/.gitignore b/src/jwtf/.gitignore
new file mode 100644
index 000000000..5eadeac89
--- /dev/null
+++ b/src/jwtf/.gitignore
@@ -0,0 +1,4 @@
+*~
+_build/
+doc/
+rebar.lock
diff --git a/src/jwtf/LICENSE b/src/jwtf/LICENSE
new file mode 100644
index 000000000..d9a10c0d8
--- /dev/null
+++ b/src/jwtf/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
diff --git a/src/jwtf/README.md b/src/jwtf/README.md
new file mode 100644
index 000000000..e6038fbc0
--- /dev/null
+++ b/src/jwtf/README.md
@@ -0,0 +1,18 @@
+# jwtf
+
+JSON Web Token Functions
+
+This library provides JWT parsing and validation functions
+
+Supports;
+
+* Verify
+* RS256
+* RS384
+* RS512
+* HS256
+* HS384
+* HS512
+* ES256
+* ES384
+* ES512
diff --git a/src/jwtf/rebar.config b/src/jwtf/rebar.config
new file mode 100644
index 000000000..e0d18443b
--- /dev/null
+++ b/src/jwtf/rebar.config
@@ -0,0 +1,2 @@
+{cover_enabled, true}.
+{cover_print_enabled, true}.
diff --git a/src/jwtf/src/jwtf.app.src b/src/jwtf/src/jwtf.app.src
new file mode 100644
index 000000000..24081bf6f
--- /dev/null
+++ b/src/jwtf/src/jwtf.app.src
@@ -0,0 +1,32 @@
+% 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,
+ public_key
+ ]},
+ {mod, {jwtf_app, []}},
+ {env,[]},
+ {modules, []},
+ {maintainers, []},
+ {licenses, []},
+ {links, []}
+]}.
diff --git a/src/jwtf/src/jwtf.erl b/src/jwtf/src/jwtf.erl
new file mode 100644
index 000000000..247f2b508
--- /dev/null
+++ b/src/jwtf/src/jwtf.erl
@@ -0,0 +1,353 @@
+% 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 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([
+ encode/3,
+ decode/3,
+ valid_algorithms/0,
+ verification_algorithm/1
+]).
+
+-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(CHECKS, [
+ alg,
+ exp,
+ iat,
+ iss,
+ kid,
+ nbf,
+ sig,
+ typ]).
+
+
+% @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({bad_request, <<"Missing alg header parameter">>});
+ Val ->
+ Val
+ end,
+ EncodedHeader = b64url:encode(jiffy:encode(Header)),
+ EncodedClaims = b64url:encode(jiffy:encode(Claims)),
+ Message = <<EncodedHeader/binary, $., EncodedClaims/binary>>,
+ 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, <<Message/binary, $., EncodedSignatureOrMac/binary>>}
+ catch
+ throw:Error ->
+ {error, Error}
+ end.
+
+
+% @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),
+ validate(Header, Payload, Signature, Checks, KS),
+ {ok, decode_b64url_json(Payload)}
+ catch
+ throw:Error ->
+ {error, Error}
+ 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()) ->
+ {atom(), atom()} | no_return().
+verification_algorithm(Alg) ->
+ case lists:keyfind(Alg, 1, ?ALGS) of
+ {Alg, Val} ->
+ Val;
+ false ->
+ throw({bad_request, <<"Invalid alg header parameter">>})
+ end.
+
+
+validate(Header0, Payload0, Signature, Checks, KS) ->
+ validate_checks(Checks),
+ Header1 = props(decode_b64url_json(Header0)),
+ validate_header(Header1, Checks),
+
+ Payload1 = props(decode_b64url_json(Payload0)),
+ validate_payload(Payload1, Checks),
+
+ Alg = prop(<<"alg">>, Header1),
+ Key = key(Header1, Checks, KS),
+ verify(Alg, Header0, Payload0, Signature, Key).
+
+
+validate_checks(Checks) when is_list(Checks) ->
+ case {lists:usort(Checks), lists:sort(Checks)} of
+ {L, L} ->
+ ok;
+ {L1, L2} ->
+ error({duplicate_checks, L2 -- L1})
+ end,
+ {_, UnknownChecks} = lists:partition(fun valid_check/1, Checks),
+ case UnknownChecks of
+ [] ->
+ ok;
+ UnknownChecks ->
+ error({unknown_checks, UnknownChecks})
+ end.
+
+
+valid_check(Check) when is_atom(Check) ->
+ lists:member(Check, ?CHECKS);
+
+valid_check({Check, _}) when is_atom(Check) ->
+ lists:member(Check, ?CHECKS);
+
+valid_check(_) ->
+ false.
+
+
+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, undefined} ->
+ ok;
+ {true, undefined} ->
+ throw({bad_request, <<"Missing typ header parameter">>});
+ {_, <<"JWT">>} ->
+ ok;
+ {true, _} ->
+ throw({bad_request, <<"Invalid typ header parameter">>})
+ end.
+
+
+validate_alg(Props, Checks) ->
+ Required = prop(alg, Checks),
+ Alg = prop(<<"alg">>, Props),
+ case {Required, Alg} of
+ {undefined, undefined} ->
+ ok;
+ {true, undefined} ->
+ throw({bad_request, <<"Missing alg header parameter">>});
+ {_, Alg} ->
+ case lists:member(Alg, valid_algorithms()) of
+ true ->
+ ok;
+ false ->
+ throw({bad_request, <<"Invalid alg header parameter">>})
+ end
+ end.
+
+
+%% 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),
+
+ case {ExpectedISS, ActualISS} of
+ {undefined, undefined} ->
+ ok;
+ {ISS, undefined} when ISS /= undefined ->
+ throw({bad_request, <<"Missing iss claim">>});
+ {ISS, ISS} ->
+ ok;
+ {_, _} ->
+ throw({bad_request, <<"Invalid iss claim">>})
+ end.
+
+
+validate_iat(Props, Checks) ->
+ Required = prop(iat, Checks),
+ IAT = prop(<<"iat">>, Props),
+
+ case {Required, IAT} of
+ {undefined, undefined} ->
+ ok;
+ {true, undefined} ->
+ throw({bad_request, <<"Missing iat claim">>});
+ {_, IAT} when is_integer(IAT) ->
+ ok;
+ {true, _} ->
+ throw({bad_request, <<"Invalid iat claim">>})
+ end.
+
+
+validate_nbf(Props, Checks) ->
+ Required = prop(nbf, Checks),
+ NBF = prop(<<"nbf">>, Props),
+
+ case {Required, NBF} of
+ {undefined, undefined} ->
+ ok;
+ {true, undefined} ->
+ throw({bad_request, <<"Missing nbf claim">>});
+ {_, IAT} ->
+ assert_past(<<"nbf">>, IAT)
+ end.
+
+
+validate_exp(Props, Checks) ->
+ Required = prop(exp, Checks),
+ EXP = prop(<<"exp">>, Props),
+
+ case {Required, EXP} of
+ {undefined, undefined} ->
+ ok;
+ {true, undefined} ->
+ throw({bad_request, <<"Missing exp claim">>});
+ {_, EXP} ->
+ assert_future(<<"exp">>, EXP)
+ end.
+
+
+key(Props, Checks, KS) ->
+ Alg = prop(<<"alg">>, Props),
+ Required = prop(kid, Checks),
+ KID = prop(<<"kid">>, Props),
+ case {Required, KID} of
+ {true, undefined} ->
+ throw({bad_request, <<"Missing kid claim">>});
+ {_, KID} ->
+ KS(Alg, KID)
+ end.
+
+
+verify(Alg, Header, Payload, SignatureOrMac0, Key) ->
+ Message = <<Header/binary, $., Payload/binary>>,
+ 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(Algorithm, Message, Signature, PublicKey) ->
+ case public_key:verify(Message, Algorithm, Signature, PublicKey) of
+ true ->
+ ok;
+ false ->
+ throw({bad_request, <<"Bad signature">>})
+ end.
+
+
+hmac_verify(Algorithm, Message, HMAC, SecretKey) ->
+ case crypto:hmac(Algorithm, SecretKey, Message) of
+ HMAC ->
+ ok;
+ _ ->
+ throw({bad_request, <<"Bad HMAC">>})
+ end.
+
+
+split(EncodedToken) ->
+ case binary:split(EncodedToken, <<$.>>, [global]) of
+ [_, _, _] = Split -> Split;
+ _ -> throw({bad_request, <<"Malformed token">>})
+ end.
+
+
+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;
+
+props(_) ->
+ throw({bad_request, <<"Not an object">>}).
+
+
+assert_past(Name, Time) ->
+ case Time < now_seconds() of
+ true ->
+ ok;
+ false ->
+ throw({unauthorized, <<Name/binary, " not in past">>})
+ end.
+
+assert_future(Name, Time) ->
+ case Time > now_seconds() of
+ true ->
+ ok;
+ false ->
+ throw({unauthorized, <<Name/binary, " not in future">>})
+ end.
+
+
+now_seconds() ->
+ {MegaSecs, Secs, _MicroSecs} = os:timestamp(),
+ MegaSecs * 1000000 + Secs.
+
+
+prop(Prop, Props) ->
+ proplists:get_value(Prop, Props).
diff --git a/src/jwtf/src/jwtf_app.erl b/src/jwtf/src/jwtf_app.erl
new file mode 100644
index 000000000..bd708e2a3
--- /dev/null
+++ b/src/jwtf/src/jwtf_app.erl
@@ -0,0 +1,28 @@
+% 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_app).
+
+-behaviour(application).
+
+%% Application callbacks
+-export([start/2, stop/1]).
+
+%% ===================================================================
+%% Application callbacks
+%% ===================================================================
+
+start(_StartType, _StartArgs) ->
+ jwtf_sup:start_link().
+
+stop(_State) ->
+ ok.
diff --git a/src/jwtf/src/jwtf_keystore.erl b/src/jwtf/src/jwtf_keystore.erl
new file mode 100644
index 000000000..be261e67c
--- /dev/null
+++ b/src/jwtf/src/jwtf_keystore.erl
@@ -0,0 +1,161 @@
+% 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_keystore).
+-behaviour(gen_server).
+-behaviour(config_listener).
+
+-include_lib("public_key/include/public_key.hrl").
+
+% public api.
+-export([
+ get/2,
+ start_link/0
+]).
+
+% gen_server api.
+-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
+ code_change/3, terminate/2]).
+
+% config_listener api
+-export([handle_config_change/5, handle_config_terminate/3]).
+
+% public functions
+
+get(Alg, undefined) when is_binary(Alg) ->
+ get(Alg, <<"_default">>);
+
+get(Alg, KID0) when is_binary(Alg), is_binary(KID0) ->
+ Kty = kty(Alg),
+ KID = binary_to_list(KID0),
+ case ets:lookup(?MODULE, {Kty, KID}) of
+ [] ->
+ Key = get_from_config(Kty, KID),
+ ok = gen_server:call(?MODULE, {set, Kty, KID, Key}),
+ Key;
+ [{{Kty, KID}, Key}] ->
+ Key
+ end.
+
+
+start_link() ->
+ gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
+
+% gen_server functions
+
+init(_) ->
+ ok = config:listen_for_changes(?MODULE, nil),
+ ets:new(?MODULE, [public, named_table]),
+ {ok, nil}.
+
+
+handle_call({set, Kty, KID, Key}, _From, State) ->
+ true = ets:insert(?MODULE, {{Kty, KID}, Key}),
+ {reply, ok, State}.
+
+
+handle_cast({delete, Kty, KID}, State) ->
+ true = ets:delete(?MODULE, {Kty, KID}),
+ {noreply, State};
+
+handle_cast(_Msg, State) ->
+ {noreply, State}.
+
+
+handle_info(restart_config_listener, State) ->
+ ok = config:listen_for_changes(?MODULE, nil),
+ {noreply, State};
+
+handle_info(_Msg, State) ->
+ {noreply, State}.
+
+
+terminate(_Reason, _State) ->
+ ok.
+
+
+code_change(_OldVsn, State, _Extra) ->
+ {ok, State}.
+
+
+% config listener callback
+
+handle_config_change("jwt_keys", ConfigKey, _ConfigValue, _, _) ->
+ case string:split(ConfigKey, ":") of
+ [Kty, KID] ->
+ gen_server:cast(?MODULE, {delete, Kty, KID});
+ _ ->
+ ignored
+ end,
+ {ok, nil};
+
+handle_config_change(_, _, _, _, _) ->
+ {ok, nil}.
+
+handle_config_terminate(_Server, stop, _State) ->
+ ok;
+
+handle_config_terminate(_Server, _Reason, _State) ->
+ erlang:send_after(100, whereis(?MODULE), restart_config_listener).
+
+% private functions
+
+get_from_config(Kty, KID) ->
+ case config:get("jwt_keys", string:join([Kty, KID], ":")) of
+ undefined ->
+ throw({bad_request, <<"Unknown kid">>});
+ Encoded ->
+ case Kty of
+ "hmac" ->
+ try
+ base64:decode(Encoded)
+ catch
+ error:_ ->
+ throw({bad_request, <<"Not a valid key">>})
+ end;
+ "rsa" ->
+ case pem_decode(Encoded) of
+ #'RSAPublicKey'{} = Key ->
+ Key;
+ _ ->
+ throw({bad_request, <<"not an RSA public key">>})
+ end;
+ "ec" ->
+ case pem_decode(Encoded) of
+ {#'ECPoint'{}, _} = Key ->
+ Key;
+ _ ->
+ throw({bad_request, <<"not an EC public key">>})
+ end
+ end
+ end.
+
+pem_decode(PEM) ->
+ BinPEM = iolist_to_binary(string:replace(PEM, "\\n", "\n", all)),
+ case public_key:pem_decode(BinPEM) of
+ [PEMEntry] ->
+ public_key:pem_entry_decode(PEMEntry);
+ [] ->
+ throw({bad_request, <<"Not a valid key">>})
+ end.
+
+kty(<<"HS", _/binary>>) ->
+ "hmac";
+
+kty(<<"RS", _/binary>>) ->
+ "rsa";
+
+kty(<<"ES", _/binary>>) ->
+ "ec";
+
+kty(_) ->
+ throw({bad_request, <<"Unknown kty">>}).
diff --git a/src/jwtf/src/jwtf_sup.erl b/src/jwtf/src/jwtf_sup.erl
new file mode 100644
index 000000000..6f44808de
--- /dev/null
+++ b/src/jwtf/src/jwtf_sup.erl
@@ -0,0 +1,38 @@
+% 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_sup).
+
+-behaviour(supervisor).
+
+%% API
+-export([start_link/0]).
+
+%% Supervisor callbacks
+-export([init/1]).
+
+%% Helper macro for declaring children of supervisor
+-define(CHILD(I, Type), {I, {I, start_link, []}, permanent, 5000, Type, [I]}).
+
+%% ===================================================================
+%% API functions
+%% ===================================================================
+
+start_link() ->
+ supervisor:start_link({local, ?MODULE}, ?MODULE, []).
+
+%% ===================================================================
+%% Supervisor callbacks
+%% ===================================================================
+
+init([]) ->
+ {ok, { {one_for_one, 5, 10}, [?CHILD(jwtf_keystore, worker)]} }.
diff --git a/src/jwtf/test/jwtf_keystore_tests.erl b/src/jwtf/test/jwtf_keystore_tests.erl
new file mode 100644
index 000000000..9ec943653
--- /dev/null
+++ b/src/jwtf/test/jwtf_keystore_tests.erl
@@ -0,0 +1,57 @@
+% 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_keystore_tests).
+
+-include_lib("eunit/include/eunit.hrl").
+-include_lib("public_key/include/public_key.hrl").
+
+-define(HMAC_SECRET, "aGVsbG8=").
+-define(RSA_SECRET, "-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAztanwQtIx0sms+x7m1SF\\nh7EHJHkM2biTJ41jR89FsDE2gd3MChpaqxemS5GpNvfFKRvuHa4PUZ3JtRCBG1KM\\n/7EWIVTy1JQDr2mb8couGlQNqz4uXN2vkNQ0XszgjU4Wn6ZpvYxmqPFbmkRe8QSn\\nAy2Wf8jQgjsbez8eaaX0G9S1hgFZUN3KFu7SVmUDQNvWpQdaJPP+ms5Z0CqF7JLa\\nvJmSdsU49nlYw9VH/XmwlUBMye6HgR4ZGCLQS85frqF0xLWvi7CsMdchcIjHudXH\\nQK1AumD/VVZVdi8Q5Qew7F6VXeXqnhbw9n6Px25cCuNuh6u5+E6GUzXRrMpqo9vO\\nqQIDAQAB\\n-----END PUBLIC KEY-----\\n").
+-define(EC_SECRET, "-----BEGIN PUBLIC KEY-----\\nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEDsr0lz/Dg3luarb+Kua0Wcj9WrfR23os\\nwHzakglb8GhWRDn+oZT0Bt/26sX8uB4/ij9PEOLHPo+IHBtX4ELFFVr5GTzlqcJe\\nyctaTDd1OOAPXYuc67EWtGZ3pDAzztRs\\n-----END PUBLIC KEY-----\\n").
+
+setup() ->
+ test_util:start_applications([config, jwtf]),
+ config:set("jwt_keys", "hmac:hmac", ?HMAC_SECRET),
+ config:set("jwt_keys", "rsa:hmac", ?HMAC_SECRET),
+ config:set("jwt_keys", "ec:hmac", ?HMAC_SECRET),
+
+ config:set("jwt_keys", "hmac:rsa", ?RSA_SECRET),
+ config:set("jwt_keys", "rsa:rsa", ?RSA_SECRET),
+ config:set("jwt_keys", "ec:rsa", ?RSA_SECRET),
+
+ config:set("jwt_keys", "hmac:ec", ?EC_SECRET),
+ config:set("jwt_keys", "rsa:ec", ?EC_SECRET),
+ config:set("jwt_keys", "ec:ec", ?EC_SECRET).
+
+teardown(_) ->
+ test_util:stop_applications([config, jwtf]).
+
+jwtf_keystore_test_() ->
+ {
+ setup,
+ fun setup/0,
+ fun teardown/1,
+ [
+ ?_assertEqual(<<"hello">>, jwtf_keystore:get(<<"HS256">>, <<"hmac">>)),
+ ?_assertThrow({bad_request, _}, jwtf_keystore:get(<<"RS256">>, <<"hmac">>)),
+ ?_assertThrow({bad_request, _}, jwtf_keystore:get(<<"ES256">>, <<"hmac">>)),
+
+ ?_assertThrow({bad_request, _}, jwtf_keystore:get(<<"HS256">>, <<"rsa">>)),
+ ?_assertMatch(#'RSAPublicKey'{}, jwtf_keystore:get(<<"RS256">>, <<"rsa">>)),
+ ?_assertThrow({bad_request, _}, jwtf_keystore:get(<<"ES256">>, <<"rsa">>)),
+
+ ?_assertThrow({bad_request, _}, jwtf_keystore:get(<<"HS256">>, <<"ec">>)),
+ ?_assertThrow({bad_request, _}, jwtf_keystore:get(<<"RS256">>, <<"ec">>)),
+ ?_assertMatch({#'ECPoint'{}, _}, jwtf_keystore:get(<<"ES256">>, <<"ec">>))
+ ]
+ }.
diff --git a/src/jwtf/test/jwtf_tests.erl b/src/jwtf/test/jwtf_tests.erl
new file mode 100644
index 000000000..ba944f7c7
--- /dev/null
+++ b/src/jwtf/test/jwtf_tests.erl
@@ -0,0 +1,317 @@
+% 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">>),
+ <<Header1/binary, $., Payload1/binary, $., Sig/binary>>.
+
+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).
+
+
+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">>}},
+ 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)).
+
+unknown_atom_check_test() ->
+ ?assertError({unknown_checks, [foo, bar]},
+ jwtf:decode(<<"a.b.c">>, [exp, foo, iss, bar], nil)).
+
+unknown_binary_check_test() ->
+ ?assertError({unknown_checks, [<<"bar">>]},
+ jwtf:decode(<<"a.b.c">>, [exp, iss, <<"bar">>], nil)).
+
+duplicate_check_test() ->
+ ?assertError({duplicate_checks, [exp]},
+ jwtf:decode(<<"a.b.c">>, [exp, exp], 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 = os:system_time(second),
+ {[
+ {<<"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.
diff --git a/test/elixir/test/config/test-config.ini b/test/elixir/test/config/test-config.ini
index 72a13a707..1980139d1 100644
--- a/test/elixir/test/config/test-config.ini
+++ b/test/elixir/test/config/test-config.ini
@@ -1,2 +1,2 @@
[chttpd]
-authentication_handlers = {chttpd_auth, proxy_authentication_handler}, {chttpd_auth, cookie_authentication_handler}, {chttpd_auth, default_authentication_handler}
+authentication_handlers = {chttpd_auth, jwt_authentication_handler}, {chttpd_auth, proxy_authentication_handler}, {chttpd_auth, cookie_authentication_handler}, {chttpd_auth, default_authentication_handler}
diff --git a/test/elixir/test/jwtauth_test.exs b/test/elixir/test/jwtauth_test.exs
new file mode 100644
index 000000000..55e075cb2
--- /dev/null
+++ b/test/elixir/test/jwtauth_test.exs
@@ -0,0 +1,139 @@
+defmodule JwtAuthTest do
+ use CouchTestCase
+
+ @moduletag :authentication
+
+ test "jwt auth with HMAC secret", _context do
+
+ secret = "zxczxc12zxczxc12"
+
+ server_config = [
+ %{
+ :section => "jwt_keys",
+ :key => "hmac:_default",
+ :value => :base64.encode(secret)
+ },
+ %{
+ :section => "jwt_auth",
+ :key => "allowed_algorithms",
+ :value => "HS256, HS384, HS512"
+ }
+ ]
+
+ run_on_modified_server(server_config, fn -> test_fun("HS256", secret) end)
+ run_on_modified_server(server_config, fn -> test_fun("HS384", secret) end)
+ run_on_modified_server(server_config, fn -> test_fun("HS512", secret) end)
+ end
+
+ defmodule RSA do
+ require Record
+ Record.defrecord :public, :RSAPublicKey,
+ Record.extract(:RSAPublicKey, from_lib: "public_key/include/public_key.hrl")
+ Record.defrecord :private, :RSAPrivateKey,
+ Record.extract(:RSAPrivateKey, from_lib: "public_key/include/public_key.hrl")
+ end
+
+ test "jwt auth with RSA secret", _context do
+ require JwtAuthTest.RSA
+
+ private_key = :public_key.generate_key({:rsa, 2048, 17})
+ public_key = RSA.public(
+ modulus: RSA.private(private_key, :modulus),
+ publicExponent: RSA.private(private_key, :publicExponent))
+
+ public_pem = :public_key.pem_encode(
+ [:public_key.pem_entry_encode(
+ :SubjectPublicKeyInfo, public_key)])
+ public_pem = String.replace(public_pem, "\n", "\\n")
+
+ server_config = [
+ %{
+ :section => "jwt_keys",
+ :key => "rsa:_default",
+ :value => public_pem
+ },
+ %{
+ :section => "jwt_auth",
+ :key => "allowed_algorithms",
+ :value => "RS256, RS384, RS512"
+ }
+ ]
+
+ run_on_modified_server(server_config, fn -> test_fun("RS256", private_key) end)
+ run_on_modified_server(server_config, fn -> test_fun("RS384", private_key) end)
+ run_on_modified_server(server_config, fn -> test_fun("RS512", private_key) end)
+ end
+
+ defmodule EC do
+ require Record
+ Record.defrecord :point, :ECPoint,
+ Record.extract(:ECPoint, from_lib: "public_key/include/public_key.hrl")
+ Record.defrecord :private, :ECPrivateKey,
+ Record.extract(:ECPrivateKey, from_lib: "public_key/include/public_key.hrl")
+ end
+
+ test "jwt auth with EC secret", _context do
+ require JwtAuthTest.EC
+
+ private_key = :public_key.generate_key({:namedCurve, :secp384r1})
+ point = EC.point(point: EC.private(private_key, :publicKey))
+ public_key = {point, EC.private(private_key, :parameters)}
+
+ public_pem = :public_key.pem_encode(
+ [:public_key.pem_entry_encode(
+ :SubjectPublicKeyInfo, public_key)])
+ public_pem = String.replace(public_pem, "\n", "\\n")
+
+ server_config = [
+ %{
+ :section => "jwt_keys",
+ :key => "ec:_default",
+ :value => public_pem
+ },
+ %{
+ :section => "jwt_auth",
+ :key => "allowed_algorithms",
+ :value => "ES256, ES384, ES512"
+ }
+ ]
+
+ run_on_modified_server(server_config, fn -> test_fun("ES256", private_key) end)
+ run_on_modified_server(server_config, fn -> test_fun("ES384", private_key) end)
+ run_on_modified_server(server_config, fn -> test_fun("ES512", private_key) end)
+ end
+
+ def test_fun(alg, key) do
+ now = DateTime.to_unix(DateTime.utc_now())
+ {:ok, token} = :jwtf.encode(
+ {
+ [
+ {"alg", alg},
+ {"typ", "JWT"}
+ ]
+ },
+ {
+ [
+ {"nbf", now - 60},
+ {"exp", now + 60},
+ {"sub", "couch@apache.org"},
+ {"_couchdb.roles", ["testing"]
+ }
+ ]
+ }, key)
+
+ resp = Couch.get("/_session",
+ headers: [authorization: "Bearer #{token}"]
+ )
+
+ assert resp.body["userCtx"]["name"] == "couch@apache.org"
+ assert resp.body["info"]["authenticated"] == "jwt"
+ end
+
+ test "jwt auth without secret", _context do
+
+ resp = Couch.get("/_session")
+
+ assert resp.body["userCtx"]["name"] == "adm"
+ assert resp.body["info"]["authenticated"] == "default"
+ end
+end