diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..19470b2 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: erlang +otp_release: + - 18.2.1 +sudo: false +addons: + apt: + packages: + - cmake + - time +script: rebar co eunit diff --git a/README.md b/README.md index 2cf785f..61a0b34 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/rebar.config b/rebar.config index 8755dc7..e6e0b63 100644 --- a/rebar.config +++ b/rebar.config @@ -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"}}} ]}. diff --git a/src/couch_jwt_auth.erl b/src/couch_jwt_auth.erl index 44f9096..0ed1636 100644 --- a/src/couch_jwt_auth.erl +++ b/src/couch_jwt_auth.erl @@ -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. @@ -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}}, @@ -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. diff --git a/src/openid_connect_configuration.erl b/src/openid_connect_configuration.erl new file mode 100644 index 0000000..e38ac63 --- /dev/null +++ b/src/openid_connect_configuration.erl @@ -0,0 +1,101 @@ +-module(openid_connect_configuration). +-export([load_jwk_set_from_config_url/1]). + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + +load_jwk_set_from_config_url(ConfigurationUrl) -> + {ok, { {_, 200, _}, _, ConfigurationJson}} = httpc:request(ConfigurationUrl), + Configuration = jsx:decode(list_to_binary(ConfigurationJson), [return_maps]), + #{<<"jwks_uri">> := JwksUri} = Configuration, + {ok, { {_, 200, _}, _, JwksJson}} = httpc:request(binary_to_list(JwksUri)), + case Jwk = jose_jwk:from(list_to_binary(JwksJson)) of + {jose_jwk, {jose_jwk_set, KeySet}, _, _} -> KeySet; + {jose_jwk, undefined, _, _} -> [Jwk]%single key + end. + + +% UNIT TESTS +-ifdef(TEST). +-define (PrivateRSAKey, "-----BEGIN RSA PRIVATE KEY----- +MIGsAgEAAiEArrS5prfIX1AaFEzmL1wq/k3k5hiAKnJZz5ev+iIivcUCAwEAAQIg +JJRektPENoC1FS8MuznXHlNpkYjvJ49mOfJQUSp9HIECEQDXY7Tq8V6j/HqgFSe8 +Qx51AhEAz6VUgN0H5+1xwyOBtq/YEQIRANO6cLLvOFBNNaG9igT3ma0CEQClL7lR +6oRnlRVzT8PZOXqBAhEAssLyiaIABERKr2aGrKyE9A== +-----END RSA PRIVATE KEY-----"). + +-define (KeySetJson, "{ +'keys': [ +{ + 'kty': 'RSA', + 'alg': 'RS256', + 'use': 'sig', + 'kid': '2e4c1fcf6e968282397fe03ab848d4e9c9c27bb3', + 'n': 'wD49pRcnhqqvg3elGfH-oP6VSKuaf9ELSYwKSq4pXPwp01lIGtareYLT0gYQw_PXOQLN_ttt8JmvPVwijr3UQ6eCiVhWnFDJFpNlaYOyCfpKEJU0q25ZiPkbQYnG1cpM9Um1YjaB7at_pdBFdkMfO0H7yHVyypmfdcHQRdMN_AdeIwfsYnR3sk83I0JzT_DB28n6YlHfsk8tfF7MIRZ6TZbhIJf_3n_RnV-i6ymY6r_818Zv9gnhYRjadVGT0vvXE0lIWTRDcYuBhlLF_Uhi_KVUr6pMVhjVFvswBh2ixAL3Aet-OK8qQj13OhC9bBVVz7lAarLhhxyG5UMdHWhIhw', + 'e': 'AQAB' +}, +{ + 'kty': 'RSA', + 'alg': 'RS256', + 'use': 'sig', + 'kid': '5243b429de18f456856093046740e05664c42996', + 'n': '5FZPC-na4vUy-Q9n0yXtMctyf8RqMcU0lAEzGgfFintDXBmMREw3-4K8XcgfpGibyG_05sGTbuVb058FgudfKmHe5rfD-Fy37R4jtpPy1Gnqhw26ytehZPhq5MWjH9sYPaTb1CFd7rZRrArTksk4iceGAyGWX2pvCVE0r5-_UWUoVW289SDqRBrv6MnxD8FYUX5-NGLThM-07AsXHtN04q19_mMKV4TfTwkUlZb-EGa88YKia16HxiVZeRaE2NVpGMJYxd-TizMtACjdd616kSt2ZUNV4Tx1C8HM779yXyu-5kiqO2niQkPnUvSLOWMcJSaE-osVHaqoLrRcEU3ugQ', + 'e': 'AQAB' +}, +{ + 'kty': 'RSA', + 'alg': 'RS256', + 'use': 'sig', + 'kid': 'f596800f80b253fcd7fa6eb50c0dd60a32cb29ec', + 'n': '5gCnR8CDlMunqB5EdXYpFhRBeTXbf-88P7CTN8v7_wPVuuXjhTuP6gnP0BnSI3l4JcYVOP65nvRzkKJVEqP2Wrom1kwQYQBkjLTze_jsYEtTaNocA9anl0OprhVy4DkytEpZ4b3EfYpr4BNxkMTEhefgUmM-HNyDUw-IHaR37tbzopcJ4dnv8K94p1mwnwb78wxLEViGXuOFCe6Nwf6K68idliekUVdQSFUocwVznCi4OffZQcsWP6wtELqBRYqmBvnHAuygZCet7rLwBy-i82f0vGZhpQvnWP8yltgvCqSGJ5J0lS0fJ9e92aD_RBb2HV9LY72z2kIeQ30p9vVOvQ', + 'e': 'AQAB' +} +] +}"). + +%don't use test generator pattern here, otherwise we run into a funny meck issue +%when trying to mock openid_connect_configuration module lateron: +%https://github.com/eproxus/meck/issues/133 +load_single_jwk_from_config_test() -> + jose:json_module(jsx), + PrivateJwk = jose_jwk:from_pem(list_to_binary(?PrivateRSAKey)), + {_, JwkJson} = jose_jwk:to_binary(PrivateJwk), + + meck:new(httpc), + meck:expect(httpc, request, [{["http://localhost/.well-known/openid-configuration"], + {ok, { {"Version",200, "Reason"}, [], "{'jwks_uri': 'http://localhost/jwks'}"}}}, + {["http://localhost/jwks"], + {ok, { {"Version",200, "Reason"}, [], binary_to_list(JwkJson)}}} + ]), + try + JwkLoaded = load_jwk_set_from_config_url("http://localhost/.well-known/openid-configuration"), + ?assertEqual([PrivateJwk], JwkLoaded) + after + meck:validate(httpc), + meck:unload(httpc) + end. + +load_jwk_set_from_from_config_test() -> + meck:new(httpc), + meck:expect(httpc, request, [{["http://localhost/.well-known/openid-configuration"], + {ok, { {"Version",200, "Reason"}, [], "{'jwks_uri': 'http://localhost/jwks'}"}}}, + {["http://localhost/jwks"], + {ok, { {"Version",200, "Reason"}, [], ?KeySetJson}}} + ]), + try + JwkLoaded = load_jwk_set_from_config_url("http://localhost/.well-known/openid-configuration"), + {jose_jwk, {jose_jwk_set, KeySet}, _, _} = jose_jwk:from(list_to_binary(?KeySetJson)), + ?assertEqual(KeySet, JwkLoaded) + after + meck:validate(httpc), + meck:unload(httpc) + end. +%setup() -> +% {ok, _} = application:ensure_all_started(inets), +% {ok, _} = application:ensure_all_started(ssl). + +%cleanup(_) -> +% ok. + +-endif. diff --git a/test/couch_jwt_auth_tests.erl b/test/couch_jwt_auth_tests.erl new file mode 100644 index 0000000..c285fb7 --- /dev/null +++ b/test/couch_jwt_auth_tests.erl @@ -0,0 +1,221 @@ +-module(couch_jwt_auth_tests). +-include_lib("eunit/include/eunit.hrl"). + +-import(couch_jwt_auth, [start_link/1, stop/0, decode/1, decode/2, decode/4, validate/3]). +-import(couch_jwt_auth, [init_jwk_from_config/1, init_jwk/1]). +-import(couch_jwt_auth, [posix_time/1, get_userinfo_from_token/2]). + +-define (NilConfig, []). +-define (EmptyConfig, [{"hs_secret",""}]). +-define (BasicConfig, [{"hs_secret","c2VjcmV0"}]). +-define (ConflictingConfig, [{"hs_secret","c2VjcmV0"}, {"rs_public_key", ".."}]). +-define (BasicTokenInfo, [{"sub",<<"1234567890">>},{"name",<<"John Doe">>},{"admin",true}]). +-define (SimpleToken, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ"). +-define (NoSignatureToken, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOmZhbHNlfQ"). +-define (NoSignatureToken2, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOmZhbHNlfQ."). +-define (UnsecuredToken, "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9."). + +-define (RS256Config, [{"rs_public_key","-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGHFHYLugdUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQsHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5Do2kQ+X5xK9cipRgEKwIDAQAB +-----END PUBLIC KEY-----"}]). +-define (RS256TokenInfo, [{"sub",<<"1234567890">>},{"name",<<"John Doe">>},{"admin",true}]). +-define (RS256Token, "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHGuERTqYZyuhtF39yxJPAjUESwxk2J5k_4zM3O-vtd1Ghyo4IbqKKSy6J9mTniYJPenn5-HIirE"). + +-define (OpenIdConfig, [{"openid_authority","http://localhost/"}]). +-define (PrivateRSAKey, "-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQDdlatRjRjogo3WojgGHFHYLugdUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQsHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5Do2kQ+X5xK9cipRgEKwIDAQABAoGAD+onAtVye4ic7VR7V50DF9bOnwRwNXrARcDhq9LWNRrRGElESYYTQ6EbatXS3MCyjjX2eMhu/aF5YhXBwkppwxg+EOmXeh+MzL7Zh284OuPbkglAaGhV9bb6/5CpuGb1esyPbYW+Ty2PC0GSZfIXkXs76jXAu9TOBvD0ybc2YlkCQQDywg2R/7t3Q2OE2+yo382CLJdrlSLVROWKwb4tb2PjhY4XAwV8d1vy0RenxTB+K5Mu57uVSTHtrMK0GAtFr833AkEA6avx20OHo61Yela/4k5kQDtjEf1N0LfI+BcWZtxsS3jDM3i1Hp0KSu5rsCPb8acJo5RO26gGVrfAsDcIXKC+bQJAZZ2XIpsitLyPpuiMOvBbzPavd4gY6Z8KWrfYzJoI/Q9FuBo6rKwl4BFoToD7WIUS+hpkagwWiz+6zLoX1dbOZwJACmH5fSSjAkLRi54PKJ8TFUeOP15h9sQzydI8zJU+upvDEKZsZc/UhT/SySDOxQ4G/523Y0sz/OZtSWcol/UMgQJALesy++GdvoIDLfJX5GBQpuFgFenRiRDabxrE9MNUZ2aPFaFp+DyAe+b4nDwuJaW2LURbr8AEZga7oQj0uYxcYw== +-----END RSA PRIVATE KEY-----"). +-define (TokenForPrivateRSAKey, "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHGuERTqYZyuhtF39yxJPAjUESwxk2J5k_4zM3O-vtd1Ghyo4IbqKKSy6J9mTniYJPenn5-HIirE"). + +-define (PrivateRSAKey2, "-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQCqdXgjtv85R+z4K+ghXY9AcFKFtSKme0I3wU7pdlyhiAPiRh+C +KM3ubb42fk0leH977UXhCiTzmtNYK9N9F6VpegmysDl3WD1M7LnpUA8JPlnGU0Nj +1pA1iYvP82o9SKNvOMG8Va1dCnv6ZKA0/4CJJCmpA/xjKx3JUHMY83uGOQIDAQAB +AoGAezm6ZQ84iB8/5tRO1jf9hBbvASvF5dY7M3UyZ8GiCz/5ls0coAqBfHinRlud +x5XJizwnBR1BQz3MxPPByq+aamxAmWEE+2Z0iFAfuumikNSZMTzzY0k9w/m+760/ +n/x5KAdwNjKycsLPhK5JYrhP/jho+LUhF1O7wAzbnZT6jyUCQQDdQte6wMwj8km+ +pkeYIsaGbPs+K48GtJeCIm1W/nA4+6N+ujrN5f02Sa407H6N4x13wh5tXHYP/rnr +rAs5JVmLAkEAxTi63UXPH8glqrDxTz0szRW28WI0DM8Gq3H1TaXSeZLLk9uhva4n +ZMlLd/6e8h3Fiq4uI9LiQbkoR1uEUecvywJAGqawgYgzjqjihRpWSVb2/r4lzSlG +AxLBpSUscmwXbGWzHdKkvqRTSbS6TRmnbMPMit5Q9+9JMUgHcQG6IFoFXQJATE6n +1mdlPWnGUSXHKB6GUA9/yiNx+ia78OfVvqZTKmDGzb2j9e0FJvTPc20b+JfWT9MW +3RuCGWXXlMxvBPWLQwJAV1XSMxwRaRm55zVqE78xwGzCOp1PIHHAyYqXmBknn/Mv +5DXn3l3kxcZVj4DEn2FAEmrpHRtKSf9X+GvPrt8rGQ== +-----END RSA PRIVATE KEY-----"). + +-define (TokenForPrivateRSAKey2WithKida, "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImEifQ.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.HoqbCf0x8cm0pbtS7PpQz21amq4w7zgApsflrvkChXUt3mvfJI_Aa46HlpAPUWOSnmeri5NRxPjEK-FSk9S1lanDnUCrXJBHQT94W6NLbtem3oNngm-BbIThEZjCi0gJxclXxO1BvtibiRxvMA-tskuxGpZBWpdMNRHgCqUlccU"). + +decoding_using_gen_server_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"load public key from openid configuration url", fun() -> + PublicKey = [jose_jwk:to_public(jose_jwk:from_pem(list_to_binary(?PrivateRSAKey)))], + meck:new(openid_connect_configuration, [no_link]), + meck:expect(openid_connect_configuration, + load_jwk_set_from_config_url, [{ ["http://localhost/.well-known/openid-configuration"], PublicKey}]), + try + init_jwk(?OpenIdConfig), + decode(?TokenForPrivateRSAKey) + after + meck:validate(openid_connect_configuration), + meck:unload(openid_connect_configuration) + end + end}, + {"reload public key from openid configuration url when first attempt of loading failed", fun() -> + meck:new(openid_connect_configuration, [no_link]), + meck:expect(openid_connect_configuration, + load_jwk_set_from_config_url, fun("http://localhost/.well-known/openid-configuration") -> meck:exception(error, stupid_error) end), + try + init_jwk(?OpenIdConfig) + after + meck:validate(openid_connect_configuration), + meck:unload(openid_connect_configuration) + end, + + meck:new(openid_connect_configuration, [no_link]), + {_, PrivateKeyMap} = jose_jwk:to_map(jose_jwk:from_pem(list_to_binary(?PrivateRSAKey2))), + PublicKeyWithKid = [jose_jwk:to_public(jose_jwk:from_map(maps:put(<<"kid">>, <<"a">>, PrivateKeyMap)))], + meck:expect(openid_connect_configuration, + load_jwk_set_from_config_url, [{ ["http://localhost/.well-known/openid-configuration"], PublicKeyWithKid}]), + try + decode(?TokenForPrivateRSAKey2WithKida) + after + meck:validate(openid_connect_configuration), + meck:unload(openid_connect_configuration) + end + end}, + {"reload public key from openid configuration url when token with new kid is given", fun() -> + PublicKey = [jose_jwk:to_public(jose_jwk:from_pem(list_to_binary(?PrivateRSAKey)))], + meck:new(openid_connect_configuration, [no_link]), + meck:expect(openid_connect_configuration, + load_jwk_set_from_config_url, [{ ["http://localhost/.well-known/openid-configuration"], PublicKey}]), + try + init_jwk(?OpenIdConfig), + decode(?TokenForPrivateRSAKey) + after + meck:validate(openid_connect_configuration), + meck:unload(openid_connect_configuration) + end, + + meck:new(openid_connect_configuration, [no_link]), + {_, PrivateKeyMap} = jose_jwk:to_map(jose_jwk:from_pem(list_to_binary(?PrivateRSAKey2))), + PublicKeyWithKid = [jose_jwk:to_public(jose_jwk:from_map(maps:put(<<"kid">>, <<"a">>, PrivateKeyMap)))], + meck:expect(openid_connect_configuration, + load_jwk_set_from_config_url, [{ ["http://localhost/.well-known/openid-configuration"], PublicKeyWithKid}]), + try + decode(?TokenForPrivateRSAKey2WithKida) + after + meck:validate(openid_connect_configuration), + meck:unload(openid_connect_configuration) + end + end}, + + {"decode malformed token, only dots", fun() -> ?assertThrow({badarg,_}, decode("...", ?EmptyConfig)) end}, + + {"decode malformed token, no signature", fun() -> ?assertThrow({badarg,_}, decode(?NoSignatureToken, ?BasicConfig)) end}, + + {"decode malformed token, no signature 2", fun() -> ?assertThrow(signature_not_valid, decode(?NoSignatureToken2, ?BasicConfig)) end}, + + %compare maps here since we don't care about the order of the keys + {"decode simple", fun () -> ?assertEqual(maps:from_list(?BasicTokenInfo), maps:from_list(decode(?SimpleToken, ?BasicConfig))) end}, + + {"decode unsecured", fun () -> ?assertThrow(signature_not_valid, decode(?UnsecuredToken, ?BasicConfig)) end}, + + %compare maps here since we don't care about the order of the keys + {"decode rs256", fun () -> ?assertEqual(maps:from_list(?RS256TokenInfo), maps:from_list(decode(?RS256Token, ?RS256Config))) end}, + + {"decode rs256 test speed when using loading jwk on each decoding", + {timeout, 20, fun decode_rs256_speed_when_loading_jwk_on_each_decoding_test_handler/0}}, + + {"decode rs256 test speed when using loading jwk on each decoding and running in parallel", + {inparallel, [{timeout, 20, fun decode_rs256_speed_when_loading_jwk_on_each_decoding_test_handler/0}, + {timeout, 20, fun decode_rs256_speed_when_loading_jwk_on_each_decoding_test_handler/0}, + {timeout, 20, fun decode_rs256_speed_when_loading_jwk_on_each_decoding_test_handler/0}]}}, + + {"decode rs256 test speed when using gen_server state", + {timeout, 20, fun decode_rs256_speed_when_using_gen_server_state_test_handler/0}}, + + {"decode rs256 test speed when using gen server state and running in parallel", + {inparallel, [{timeout, 20, fun decode_rs256_speed_when_using_gen_server_state_test_handler/0}, + {timeout, 20, fun decode_rs256_speed_when_using_gen_server_state_test_handler/0}, + {timeout, 20, fun decode_rs256_speed_when_using_gen_server_state_test_handler/0}]}} + ]}. + +setup() -> + {ok, Pid} = start_link(?EmptyConfig), + Pid. + +cleanup(_) -> + stop(). + +decode_rs256_speed_when_loading_jwk_on_each_decoding_test_handler() -> + lists:map(fun(_) -> + #{rs256 := #{default := JWK}} = init_jwk_from_config(?RS256Config), + decode(?RS256Token, JWK, <<"RS256">>, ?RS256Config) end, lists:seq(1, 10000)). + +decode_rs256_speed_when_using_gen_server_state_test_handler() -> + init_jwk(?RS256Config), + lists:map(fun(_) -> decode(?RS256Token) end, lists:seq(1, 10000)). + + +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).