Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow JWTs signed with RS256 asymmetric keys. #4

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b400637
Change ejwt to jose for JWT verification.
dmunch Apr 22, 2016
b5b6fd6
Implement JWT signed with RS256 asymmetric keys.
dmunch Apr 22, 2016
27e6dd5
Use a gen_server to capsulate JWK in state.
dmunch Apr 23, 2016
540f6b2
Add performance tests to see if it's worth to use gen_server instead …
dmunch Apr 23, 2016
9244d08
Cleanup tests a little.
dmunch Apr 23, 2016
92d0909
Add another performance tests to carry out work in parallel. We could…
dmunch Apr 24, 2016
a6a9a07
Add .travis.yml
dmunch Apr 24, 2016
a230b38
Export decode/2 as well to avoid compilation issues.
dmunch Apr 24, 2016
2cfe809
Add Travis CI Build Status to README.md
dmunch Apr 24, 2016
b52a930
Move tests into their own module.
dmunch Apr 24, 2016
327da34
Refactor tests by using test generator pattern. Allows us to determin…
dmunch Apr 30, 2016
d7c65d4
Add module openId_connect_configuration
dmunch May 1, 2016
654f927
Test openid_connect_configuration with meck instead of relying on Goo…
dmunch May 1, 2016
baf58b0
Add parameter 'openid_authority' to config file. If it's given couch_…
dmunch May 1, 2016
3d93d15
Most of the time the opened-configuration url contains a list of keys…
dmunch May 13, 2016
acbd241
Allow multiple keys by leveraging the key id (kid). This is a prepara…
dmunch May 16, 2016
a82cffb
load_jwk_from_config_url becomes load_jwk_set_from_config_url and now…
dmunch May 16, 2016
bcb6036
When a token specifies a key id (kid) we don't know yet, we now try t…
dmunch May 17, 2016
2e776fd
Fix issue when loading keys from openid_authority server fails. Fixes…
dmunch Aug 15, 2016
36b0a72
Pin dependency versions.
dmunch Aug 15, 2016
06b5b31
Update jose to 1.8.0. We need to set the json_module explicitly, and …
dmunch Oct 16, 2016
e28a068
Make it compile for CouchDB 2.0
dmunch Oct 17, 2016
a842b03
Mixed up CouchDB 2.0 and CouchDB 1.6 in conditional compilation...
dmunch Nov 8, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
language: erlang
otp_release:
- 18.2.1
sudo: false
addons:
apt:
packages:
- cmake
- time
script: rebar co eunit
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# couch_jwt_auth

[![Build Status](https://travis-ci.org/dmunch/couch_jwt_auth.svg?branch=master)](https://travis-ci.org/dmunch/couch_jwt_auth)

couch_jwt_auth is authentication plugin for CouchDB. It accepts JSON Web Token in the Authorization
HTTP header and creates CouchDB user context from the token information. couch_jwt_auth doesn't use
CouchDB authentication database. User roles are read directly from JWT and not from the
Expand Down
4 changes: 3 additions & 1 deletion rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
{eunit_opts, [verbose, {report,{eunit_surefire,[{dir,"."}]}}]}.
{clean_files, ["*.eunit", "ebin/*.beam"]}.
{deps, [
{ejwt, ".*", {git, "https://github.com/artefactop/ejwt"}}
{jose, ".*", {git, "git://github.com/potatosalad/erlang-jose.git", {tag, "1.8.0"}}},
{jsx, ".*", {git, "git://github.com/talentdeficit/jsx.git", {tag, "2.8.0"}}},
{meck, ".*", {git, "https://github.com/eproxus/meck.git", {tag, "0.8.4"}}}
]}.
274 changes: 172 additions & 102 deletions src/couch_jwt_auth.erl
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,39 @@
% limitations under the License.

-module(couch_jwt_auth).
-behaviour(gen_server).

-export([start_link/0, start_link/1, stop/0, init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).

-export([jwt_authentication_handler/1]).
-export([init_jwk_from_config/1]).
-export([init_jwk/1]).
-export([decode/1]).
-export([decode/2]).
-export([decode/4]).
-export([validate/3]).

-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-export([posix_time/1]).
-export([get_userinfo_from_token/2]).

-include_lib("couch/include/couch_db.hrl").


-ifndef(LOG_INFO).
%we're most certainly compiling for CouchDB 2.0
-define(LOG_INFO(Format, Args), couch_log:info(Format, Args)).
-define(CONFIG_GET(Section), config:get(Section)).
-else.
-define(CONFIG_GET(Section), couch_config:get(Section)).
-endif.

-ifndef(JSON_DECODE).
%on CouchDB 2.0 JSON_DECODE is no longer defined
-define(LOG_INFO(Format, Args), couch_log:info(Format, Args)).
-define(JSON_DECODE(Json), jsx:decode(list_to_binary(Json))).
-endif.

-import(couch_httpd, [header_value/2]).

%% @doc token authentication handler.
Expand All @@ -40,23 +64,159 @@ jwt_authentication_handler(Req) ->
_ -> Req
end.

start_link(Config) ->
gen_server:start_link({local, ?MODULE}, ?MODULE, Config, []).

start_link() ->
Config = ?CONFIG_GET("jwt_auth"),
start_link(Config).

init([]) ->
{ok, {invalid_jwk, empty_initialization}};

init(Config) ->
try init_jwk_from_config(Config) of
JwkSet -> {ok, {valid_jwk, JwkSet, Config}}
catch
_:Error -> {ok, {invalid_jwk, Error}}
end.

handle_call({get_jwk}, _From, State) ->
{reply, State, State};

handle_call({init_jwk, Config}, _From, State) ->
try init_jwk_from_config(Config) of
JwkSet -> {reply, {ok}, {valid_jwk, JwkSet, Config}}
catch
_:Error -> {reply, {error, Error}, State}
end;

handle_call({reload_jwk, Config}, _From, State) ->
try load_jwk_set_from_url_in_config(Config) of
RsJwkSet ->
{valid_jwk, #{hs256 := HsKeys, rs256 := RsKeys}, _} = State,
NewJwkSet = #{hs256 => HsKeys, rs256 => maps:merge(RsKeys, RsJwkSet)},
{reply, {ok}, {valid_jwk, NewJwkSet, Config}}
catch
_:Error -> {reply, {error, Error}, State}
end;

handle_call(stop, _From, State) ->
{stop, normal, ok, State}.

handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(_Msg, State) ->
{noreply, State}.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
terminate(_Reason, _State) ->
ok.

init_jwk_from_config(Config) ->
jose:json_module(jsx),
HsKeys = case couch_util:get_value("hs_secret", Config, nil) of
nil -> #{};
HsSecret -> #{default =>
#{
<<"kty">> => <<"oct">>,
<<"k">> => HsSecret
}}
end,

RsPEMKey = case couch_util:get_value("rs_public_key", Config, nil) of
nil -> #{};
RsPublicKey -> #{default => jose_jwk:from_pem(list_to_binary(RsPublicKey))}
end,

RSOpenIdKeys = load_jwk_set_from_url_in_config(Config),
#{hs256 => HsKeys, rs256 => maps:merge(RsPEMKey, RSOpenIdKeys)}.

load_jwk_set_from_url_in_config(Config) ->
case couch_util:get_value("openid_authority", Config, nil) of
nil -> #{};
OpenIdAuthority ->
ConfigUri = OpenIdAuthority ++ ".well-known/openid-configuration",
?LOG_INFO("Loading public key from ~s", [ConfigUri]),

try
KeySet = openid_connect_configuration:load_jwk_set_from_config_url(ConfigUri),
maps:from_list(lists:map(fun(Key) -> case Key of
{jose_jwk, _, _, #{<<"kid">> := Kid}} -> {Kid, Key};
{jose_jwk, _, _, _} -> {default, Key}
end end, KeySet))
catch
_:_-> ?LOG_INFO("Failed loading public key from ~s", [ConfigUri]),
#{} %return an empty map
end
end.

decode(Token, JWK, Alg, Config) ->
case jose_jwt:verify_strict(JWK, [Alg], list_to_binary(Token)) of
{false, _, _} -> throw(signature_not_valid);
{true, {jose_jwt, Jwt}, _} -> validate(lists:map(fun({Key, Value}) ->
{?b2l(Key), Value}
end, maps:to_list(Jwt)), posix_time(calendar:universal_time()), Config)
end.

%% @doc decode and validate JWT using CouchDB config
-spec decode(Token :: binary()) -> list().
decode(Token) ->
decode(Token, couch_config:get("jwt_auth")).
try jose_jwt:peek_protected(list_to_binary(Token)) of
TokenProtected -> case TokenProtected of
{jose_jws, {jose_jws_alg_hmac, 'HS256'}, _, #{<<"kid">> := Kid}} -> decode(Token, hs256, Kid);
{jose_jws, {jose_jws_alg_hmac, 'HS256'}, _, _} -> decode(Token, hs256, default);
{jose_jws, {jose_jws_alg_rsa_pkcs1_v1_5, 'RS256'}, _, #{<<"kid">> := Kid ,<<"typ">> := <<"JWT">>}} -> decode(Token, rs256, Kid);
{jose_jws, {jose_jws_alg_rsa_pkcs1_v1_5, 'RS256'}, _, _} -> decode(Token, rs256, default);
_ -> throw(signature_not_valid)
end
catch
_:Error -> throw(Error)
end.

decode(Token, Alg, Kid) ->
case {Alg, gen_server:call(?MODULE, {get_jwk})} of
{hs256, {valid_jwk, JwkSet, Config}} ->
case maps:find(Kid, maps:get(Alg, JwkSet)) of
{ok, Jwk} ->decode(Token, Jwk, <<"HS256">>, Config);
error -> throw(key_not_found) %no way of getting a new key on HS256
end;
{rs256, {valid_jwk, JwkSet, Config}} ->
case maps:find(Kid, maps:get(Alg, JwkSet)) of
{ok, Jwk} ->decode(Token, Jwk, <<"RS256">>, Config);
error -> %key wasn't found. we reload from openid_authority to see if it's a new key after a key rotation
case gen_server:call(?MODULE, {reload_jwk, Config}) of
{ok} -> ok;
{error, ReloadError} -> throw(ReloadError)
end,
case gen_server:call(?MODULE, {get_jwk}) of
{valid_jwk, NewJwkSet, _} ->
case maps:find(Kid, maps:get(Alg, NewJwkSet)) of
{ok, NewJwk} -> decode(Token, NewJwk, <<"RS256">>, Config);
error -> throw(key_not_find)
end;
{invalid_jwk, Error} -> throw(Error)
end;
{invalid_jwk, Error} -> throw(Error)
end;
{_, {invalid_jwk, Error}} -> throw(Error)
end.

init_jwk(Config) ->
case gen_server:call(?MODULE, {init_jwk, Config}) of
{ok} -> ok;
{error, Error} -> throw(Error)
end.

stop() ->
gen_server:call(?MODULE, stop).

% Config is list of key value pairs:
% [{"hs_secret","..."},{"roles_claim","roles"},{"username_claim","sub"}]
-spec decode(Token :: binary(), Config :: list()) -> list().
decode(Token, Config) ->
Secret = base64url:decode(couch_util:get_value("hs_secret", Config)),
case List = ejwt:decode(list_to_binary(Token), Secret) of
error -> throw(signature_not_valid);
_ -> validate(lists:map(fun({Key, Value}) ->
{?b2l(Key), Value}
end, List), posix_time(calendar:universal_time()), Config)
end.
init_jwk(Config),
decode(Token).

posix_time({Date,Time}) ->
PosixEpoch = {{1970,1,1},{0,0,0}},
Expand Down Expand Up @@ -91,100 +251,10 @@ validate(TokenInfo, NowSeconds, Config) ->
end.

token_auth_user(Req, User) ->
{UserName, Roles} = get_userinfo_from_token(User, couch_config:get("jwt_auth")),
{UserName, Roles} = get_userinfo_from_token(User, ?CONFIG_GET("jwt_auth")),
Req#httpd{user_ctx=#user_ctx{name=UserName, roles=Roles}}.

get_userinfo_from_token(User, Config) ->
UserName = couch_util:get_value(couch_util:get_value("username_claim", Config, "sub"), User, null),
Roles = couch_util:get_value(couch_util:get_value("roles_claim", Config, "roles"), User, []),
{UserName, Roles}.

% UNIT TESTS
-ifdef(TEST).

-define (EmptyConfig, [{"hs_secret",""}]).
-define (BasicConfig, [{"hs_secret","c2VjcmV0"}]).
-define (BasicTokenInfo, [{"sub",<<"1234567890">>},{"name",<<"John Doe">>},{"admin",true}]).

decode_malformed_empty_test() ->
?assertError({badmatch,_}, decode("", ?EmptyConfig)).

decode_malformed_dots_test() ->
?assertError({badarg,_}, decode("...", ?EmptyConfig)).

decode_malformed_nosignature1_test() ->
?assertError({badmatch,_}, decode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOmZhbHNlfQ", ?BasicConfig)).

decode_malformed_nosignature2_test() ->
?assertThrow(signature_not_valid, decode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOmZhbHNlfQ.", ?BasicConfig)).

decode_simple_test() ->
TokenInfo = ?BasicTokenInfo,
?assertEqual(TokenInfo, decode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ", ?BasicConfig)).

decode_unsecured_test() ->
?assertError(function_clause, decode("eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.", ?BasicConfig)).

validate_simple_test() ->
TokenInfo = ?BasicTokenInfo,
?assertEqual(TokenInfo, validate(TokenInfo, 1000, ?EmptyConfig)).

validate_exp_nbf_test() ->
TokenInfo = lists:append([?BasicTokenInfo,[{"exp",2000}, {"nbf",900}]]),
?assertEqual(TokenInfo, validate(TokenInfo, 1000, ?EmptyConfig)).

validate_exp_rejected_test() ->
TokenInfo = lists:append([?BasicTokenInfo,[{"exp",2000}]]),
?assertThrow(token_rejected, validate(TokenInfo, 3000, ?EmptyConfig)).

validate_nbf_rejected_test() ->
TokenInfo = lists:append([?BasicTokenInfo,[{"nbf",2000}, {"exp",3000}]]),
?assertThrow(token_rejected, validate(TokenInfo, 1000, ?EmptyConfig)).

validate_aud1_rejected_test() ->
TokenInfo = lists:append([?BasicTokenInfo,[{"aud",<<"123">>}]]),
Config = lists:append([?EmptyConfig, [{"validated_claims", "aud"}, {"validate_claim_aud", "[\"456\"]"}]]),
?assertThrow(token_rejected, validate(TokenInfo, 1000, Config)).

validate_aud2_rejected_test() ->
TokenInfo = lists:append([?BasicTokenInfo,[{"aud",[<<"123">>,<<"234">>]}]]),
Config = lists:append([?EmptyConfig, [{"validated_claims", "aud"}, {"validate_claim_aud", "[\"456\"]"}]]),
?assertThrow(token_rejected, validate(TokenInfo, 1000, Config)).

validate_aud_pass_test() ->
TokenInfo = lists:append([?BasicTokenInfo,[{"aud",[<<"123">>,<<"234">>]}]]),
Config = lists:append([?EmptyConfig, [{"validated_claims", "aud"}, {"validate_claim_aud", "[\"123\",\"456\"]"}]]),
?assertEqual(TokenInfo, validate(TokenInfo, 1000, Config)).

validate_claims_pass_test() ->
TokenInfo = lists:append([?BasicTokenInfo,[{"aud",<<"123">>}, {"iss",<<"abc">>}]]),
Config = lists:append([?EmptyConfig, [{"validated_claims", "aud,iss"}, {"validate_claim_aud", "[\"123\"]"},{"validate_claim_iss", "[\"abc\"]"}]]),
?assertEqual(TokenInfo, validate(TokenInfo, 1000, Config)).

posix_time1_test() ->
?assertEqual(31536000, posix_time({{1971,1,1}, {0,0,0}})).

posix_time2_test() ->
?assertEqual(31536001, posix_time({{1971,1,1}, {0,0,1}})).

get_userinfo_from_token_default_test() ->
TokenInfo = ?BasicTokenInfo,
{UserName, Roles} = get_userinfo_from_token(TokenInfo, ?EmptyConfig),
?assertEqual([], Roles),
?assertEqual(<<"1234567890">>, UserName).

get_userinfo_from_token_configured_test() ->
TokenInfo = ?BasicTokenInfo,
Config = lists:append([?EmptyConfig, [{"username_claim", "name"}]]),
{UserName, Roles} = get_userinfo_from_token(TokenInfo, Config),
?assertEqual([], Roles),
?assertEqual(<<"John Doe">>, UserName).

% user context is created with null username if username claim is not found from token
get_userinfo_from_token_name_not_found_test() ->
TokenInfo = lists:append([?BasicTokenInfo,[{"roles",[<<"123">>]}]]),
Config = lists:append([?EmptyConfig, [{"username_claim", "doesntexist"}]]),
{UserName, Roles} = get_userinfo_from_token(TokenInfo, Config),
?assertEqual([<<"123">>], Roles),
?assertEqual(null, UserName).
-endif.
Loading