├── .github └── workflows │ └── erlang.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── elvis.config ├── include └── oauth2c.hrl ├── rebar.config ├── rebar.lock ├── src ├── oauth2c.app.src ├── oauth2c.erl ├── oauth2c_app.erl ├── oauth2c_sup.erl └── oauth2c_token_cache.erl └── test ├── oauth2c_SUITE.erl └── oauth2c_token_cache_SUITE.erl /.github/workflows/erlang.yml: -------------------------------------------------------------------------------- 1 | name: Erlang CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | 7 | build_and_test: 8 | 9 | runs-on: ubuntu-latest 10 | 11 | name: OTP ${{matrix.otp}} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | otp: ["25", "26", "27"] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: erlef/setup-beam@v1.16.0 19 | with: 20 | otp-version: ${{matrix.otp}} 21 | rebar3-version: "3.24.0" 22 | - name: Compile 23 | run: make 24 | - name: Run elvis 25 | run: make elvis_rock 26 | - name: Run xref 27 | run: make xref 28 | - name: Run dialyzer 29 | run: make dialyze 30 | - name: Run common tests 31 | run: make ct 32 | 33 | 34 | release: 35 | if: github.ref == 'refs/heads/master' && startsWith(github.event.head_commit.message, 'no-release:') == false 36 | needs: build_and_test 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Bump version and push tag 40 | id: tag_version 41 | uses: mathieudutour/github-tag-action@v5.3 42 | with: 43 | github_token: ${{ secrets.GITHUB_TOKEN }} 44 | - name: Create a GitHub release 45 | uses: actions/create-release@v1 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | tag_name: ${{ steps.tag_version.outputs.new_tag }} 50 | release_name: Release ${{ steps.tag_version.outputs.new_tag }} 51 | body: ${{ steps.tag_version.outputs.changelog }} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .eunit 2 | deps 3 | priv 4 | *.o 5 | *.beam 6 | *.plt 7 | ebin 8 | _build 9 | .elvis 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 KIVRA 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ELVIS_IN_PATH := $(shell elvis --version 2> /dev/null) 2 | ELVIS_LOCAL := $(shell .elvis/_build/default/bin/elvis --version 2> /dev/null) 3 | 4 | all: compile 5 | 6 | compile: 7 | rebar3 compile 8 | 9 | clean: 10 | rebar3 clean 11 | 12 | eunit: 13 | rebar3 eunit 14 | 15 | ct: 16 | rebar3 ct -v 17 | 18 | xref: 19 | rebar3 xref 20 | 21 | dialyze: 22 | rebar3 dialyzer 23 | 24 | upgrade: 25 | rebar3 upgrade 26 | 27 | unlock: 28 | rebar3 unlock 29 | 30 | lock: 31 | rebar3 lock 32 | 33 | elvis: 34 | ifdef ELVIS_IN_PATH 35 | elvis git-branch origin/HEAD -V 36 | else ifdef ELVIS_LOCAL 37 | .elvis/_build/default/bin/elvis git-branch origin/HEAD -V 38 | else 39 | $(MAKE) compile_elvis 40 | .elvis/_build/default/bin/elvis git-branch origin/HEAD -V 41 | endif 42 | 43 | elvis_rock: 44 | ifdef ELVIS_IN_PATH 45 | elvis rock 46 | else ifdef ELVIS_LOCAL 47 | .elvis/_build/default/bin/elvis rock 48 | else 49 | $(MAKE) compile_elvis 50 | .elvis/_build/default/bin/elvis rock 51 | endif 52 | 53 | compile_elvis: 54 | git clone https://github.com/inaka/elvis.git .elvis && \ 55 | cd .elvis && \ 56 | rebar3 compile && \ 57 | rebar3 escriptize && \ 58 | cd .. 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oauth2 Client 2 | This library is designed to simplify consuming Oauth2 enabled REST Services. It wraps a restclient and takes care of reauthenticating expired access_tokens when needed. 3 | 4 | ## Flows 5 | 6 | Implemented flows are: 7 | 8 | - Client Credentials Grant 9 | - Resource Owner Password Credentials Grant 10 | 11 | ## Example 12 | 13 | Retrieve a client with access_token using Password Credentials Grant 14 | 15 | ```erlang 16 | 1> oauth2c:retrieve_access_token(<<"password">>, <<"Url">>, <<"Uid">>, <<"Pwd">>). 17 | {ok, Headers, Client} 18 | ``` 19 | 20 | Retrieve a client with access_token using Client Credentials Grant 21 | 22 | ```erlang 23 | 2> oauth2c:retrieve_access_token(<<"client_credentials">>, <<"Url">>, <<"Client">>, <<"Secret">>). 24 | {ok, Headers, Client} 25 | ``` 26 | 27 | **Microsoft Azure AD**: Since parameters are different please use `<<"azure_client_credentials">>` as `Type` when retrieving an access token for that service. Be sure to set a `Scope` if you want to access any of the connected APIs. 28 | 29 | ```erlang 30 | 2> oauth2c:retrieve_access_token( 31 | <<"azure_client_credentials">>, 32 | <<"some_tenant_specific_oauth_token_endpoint">>, 33 | <<"some_registered_app_id">>, 34 | <<"some_created_key">>, 35 | <<"https://graph.microsoft.com">>). 36 | {ok, Headers, Client} 37 | ``` 38 | 39 | The Opaque `Client` object is to be used on subsequent requests like: 40 | 41 | ```erlang 42 | 3> oauth2c:request(get, json, <<"Url">>, [200], Client). 43 | {{ok, Status, Headers, Body} Client2} 44 | ``` 45 | 46 | See [restclient](https://github.com/kivra/restclient) for more info on how requests work. 47 | 48 | ## Twitter Example 49 | 50 | ```erlang 51 | -module(oauth2c_twitter_example). 52 | 53 | -export([ run/0 54 | ]). 55 | 56 | -define(CONSUMER_SECRET, <<"my_consumer_secret">>). 57 | -define(CONSUMER_KEY, <<"my_consumer_key">>). 58 | 59 | -define(OAUTH2_TOKEN_URL, <<"https://api.twitter.com/oauth2/token">>). 60 | 61 | -define(USER_TIMELINE_URL(User, StrCount), 62 | <<"https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=" 63 | , User, "&count=", StrCount>>). 64 | 65 | -define(APP_LIMITS_URL(Resources), 66 | << "https://api.twitter.com/1.1/application/rate_limit_status.json?resources=" 67 | , Resources>>). 68 | run() -> 69 | application:ensure_all_started(oauth2c), 70 | application:ensure_all_started(ssl), 71 | {ok, _Headers, Client} = 72 | oauth2c:retrieve_access_token( 73 | <<"client_credentials">>, ?OAUTH2_TOKEN_URL, ?CONSUMER_KEY, 74 | ?CONSUMER_SECRET), 75 | {{ok, _Status1, _Headers1, Tweets}, Client2} = 76 | oauth2c:request( 77 | get, json, ?USER_TIMELINE_URL("twitterapi", "4"), [200], Client), 78 | io:format("Tweets: ~p~n", [Tweets]), 79 | {{ok, _Status2, _Headers2, Limits}, _Client3} = 80 | oauth2c:request( 81 | get, json, ?APP_LIMITS_URL("help,users,search,statuses"), 82 | [200], Client2), 83 | io:format("Limits: ~p~n", [Limits]), 84 | ok. 85 | ``` 86 | 87 | ## License 88 | The KIVRA oauth2 library uses an [MIT license](http://en.wikipedia.org/wiki/MIT_License). So go ahead and do what 89 | you want! 90 | -------------------------------------------------------------------------------- /elvis.config: -------------------------------------------------------------------------------- 1 | %% -*- erlang -*- 2 | [ {elvis, 3 | [ {config, 4 | [ #{dirs => [ "src/*" 5 | , "src" 6 | ], 7 | filter => "*.erl", 8 | ignore => [], 9 | rules => [ {elvis_text_style, line_length, 10 | #{ limit => 100, 11 | skip_comments => false 12 | }} 13 | , {elvis_text_style, no_tabs} 14 | , {elvis_text_style, no_trailing_whitespace} 15 | , {elvis_style, macro_module_names} 16 | , {elvis_style, nesting_level, 17 | #{ level => 3, 18 | ignore => [ 19 | ] 20 | }} 21 | , {elvis_style, god_modules, 22 | #{ limit => 25, 23 | ignore => [ 24 | ] 25 | }} 26 | , {elvis_style, no_nested_try_catch, 27 | #{ignore => [ 28 | ] 29 | }} 30 | , {elvis_style, invalid_dynamic_call, 31 | #{ignore => [ 32 | ] 33 | }} 34 | , {elvis_style, used_ignored_variable} 35 | , {elvis_style, no_behavior_info} 36 | , {elvis_style, module_naming_convention, 37 | #{ ignore => [], 38 | regex => "^([a-z][a-z0-9]*_?)([a-z0-9]*_?)*$" 39 | }} 40 | , {elvis_style, function_naming_convention, 41 | #{ regex => "^([a-z][a-z0-9]*_?)([a-z0-9]*_?)*$" 42 | }} 43 | , {elvis_style, variable_naming_convention, 44 | #{ regex => "^_?([A-Z][0-9a-zA-Z_]*)$" 45 | }} 46 | , {elvis_style, state_record_and_type} 47 | , {elvis_style, no_spec_with_records} 48 | , {elvis_style, dont_repeat_yourself, 49 | #{ min_complexity => 25, 50 | ignore => [ 51 | ] 52 | }} 53 | ] 54 | }, 55 | #{dirs => [ "test" 56 | ], 57 | filter => "*.erl", 58 | rules => [ {elvis_text_style, line_length, 59 | #{ limit => 100, 60 | skip_comments => false 61 | }} 62 | , {elvis_text_style, no_tabs} 63 | , {elvis_text_style, no_trailing_whitespace} 64 | , {elvis_style, macro_module_names} 65 | , {elvis_style, no_debug_call, 66 | #{ignore => [ 67 | ] 68 | }} 69 | ] 70 | } 71 | ] 72 | } 73 | ] 74 | } 75 | ]. 76 | 77 | %%%_* Emacs ==================================================================== 78 | %%% Local Variables: 79 | %%% allout-layout: t 80 | %%% erlang-indent-level: 2 81 | %%% End: 82 | -------------------------------------------------------------------------------- /include/oauth2c.hrl: -------------------------------------------------------------------------------- 1 | -record(service_account, 2 | { private_key :: binary() 3 | , project_id :: binary() 4 | , iss :: binary() 5 | , aud :: binary() 6 | }). 7 | 8 | -record(client, {grant_type = undefined :: binary() | undefined, 9 | auth_url = undefined :: binary() | undefined, 10 | access_token = undefined :: binary() | undefined, 11 | token_type = undefined :: token_type() | undefined, 12 | refresh_token = undefined :: binary() | undefined, 13 | id = undefined :: binary() | undefined, 14 | secret = undefined :: binary() | undefined, 15 | service = undefined :: #service_account{} | undefined, 16 | scope = undefined :: binary() | undefined, 17 | expire_time = undefined :: integer() | undefined 18 | }). 19 | 20 | -type method() :: head | 21 | get | 22 | put | 23 | patch | 24 | post | 25 | trace | 26 | options | 27 | delete. 28 | -type url() :: binary(). 29 | %% <<"password">> or <<"client_credentials">> 30 | -type at_type() :: binary(). 31 | -type headers() :: restc:headers(). 32 | -type header() :: restc:header(). 33 | -type status_codes() :: [status_code()]. 34 | -type status_code() :: integer(). 35 | -type reason() :: term(). 36 | -type content_type() :: json | xml | percent. 37 | -type property() :: atom() | tuple(). 38 | -type proplist() :: [property()]. 39 | -type options() :: proplist(). 40 | -type body() :: restc:body(). 41 | -type response() :: {restc:response(), #client{}}. 42 | -type token_type() :: bearer | unsupported. 43 | -type client() :: #client{}. 44 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | %% ---------------------------------------------------------------------------- 2 | %% 3 | %% oauth2: Erlang OAuth 2.0 Client 4 | %% 5 | %% Copyright (c) 2012 KIVRA 6 | %% 7 | %% Permission is hereby granted, free of charge, to any person obtaining a 8 | %% copy of this software and associated documentation files (the "Software"), 9 | %% to deal in the Software without restriction, including without limitation 10 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 11 | %% and/or sell copies of the Software, and to permit persons to whom the 12 | %% Software is furnished to do so, subject to the following conditions: 13 | %% 14 | %% The above copyright notice and this permission notice shall be included in 15 | %% all copies or substantial portions of the Software. 16 | %% 17 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | %% DEALINGS IN THE SOFTWARE. 24 | %% 25 | %% ---------------------------------------------------------------------------- 26 | 27 | {deps, 28 | [ {restc, ".*", {git, "https://github.com/kivra/restclient.git", {tag, "0.9.10"}}} 29 | , {jose, {git, "https://github.com/potatosalad/erlang-jose.git", {tag, "1.11.6"}}} 30 | ]}. 31 | 32 | {profiles, 33 | [{test, [ 34 | {erl_opts, [nowarn_export_all]}, 35 | {deps, [ {meck, "0.8.13"} 36 | ]} 37 | ]} 38 | ]}. 39 | 40 | {erl_opts, [ 41 | warnings_as_errors, 42 | warn_export_all 43 | ]}. 44 | 45 | {xref_checks,[ 46 | %% enable most checks, but avoid 'unused calls' which is often 47 | %% very verbose 48 | undefined_function_calls, undefined_functions, locals_not_used, 49 | deprecated_function_calls, deprecated_functions, deprecated_functions 50 | ]}. 51 | -------------------------------------------------------------------------------- /rebar.lock: -------------------------------------------------------------------------------- 1 | {"1.2.0", 2 | [{<<"base64url">>,{pkg,<<"base64url">>,<<"0.0.1">>},1}, 3 | {<<"certifi">>,{pkg,<<"certifi">>,<<"2.12.0">>},2}, 4 | {<<"erlsom">>,{pkg,<<"erlsom">>,<<"1.5.1">>},1}, 5 | {<<"hackney">>,{pkg,<<"hackney">>,<<"1.20.1">>},1}, 6 | {<<"idna">>,{pkg,<<"idna">>,<<"6.1.1">>},2}, 7 | {<<"jose">>, 8 | {git,"https://github.com/potatosalad/erlang-jose.git", 9 | {ref,"090a2ed054304ecc012d6c2d9d10d2a294d835b1"}}, 10 | 0}, 11 | {<<"jsx">>,{pkg,<<"jsx">>,<<"3.1.0">>},1}, 12 | {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},2}, 13 | {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.3.0">>},2}, 14 | {<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.4.1">>},2}, 15 | {<<"restc">>, 16 | {git,"https://github.com/kivra/restclient.git", 17 | {ref,"597d8162b4a9f547046f5ca604c93908b55e7e2a"}}, 18 | 0}, 19 | {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.7">>},2}, 20 | {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.0">>},2}]}. 21 | [ 22 | {pkg_hash,[ 23 | {<<"base64url">>, <<"36A90125F5948E3AFD7BE97662A1504B934DD5DAC78451CA6E9ABF85A10286BE">>}, 24 | {<<"certifi">>, <<"2D1CCA2EC95F59643862AF91F001478C9863C2AC9CB6E2F89780BFD8DE987329">>}, 25 | {<<"erlsom">>, <<"C8FE2BABD33FF0846403F6522328B8AB676F896B793634CFE7EF181C05316C03">>}, 26 | {<<"hackney">>, <<"8D97AEC62DDDDD757D128BFD1DF6C5861093419F8F7A4223823537BAD5D064E2">>}, 27 | {<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>}, 28 | {<<"jsx">>, <<"D12516BAA0BB23A59BB35DCCAF02A1BD08243FCBB9EFE24F2D9D056CCFF71268">>}, 29 | {<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>}, 30 | {<<"mimerl">>, <<"D0CD9FC04B9061F82490F6581E0128379830E78535E017F7780F37FEA7545726">>}, 31 | {<<"parse_trans">>, <<"6E6AA8167CB44CC8F39441D05193BE6E6F4E7C2946CB2759F015F8C56B76E5FF">>}, 32 | {<<"ssl_verify_fun">>, <<"354C321CF377240C7B8716899E182CE4890C5938111A1296ADD3EC74CF1715DF">>}, 33 | {<<"unicode_util_compat">>, <<"BC84380C9AB48177092F43AC89E4DFA2C6D62B40B8BD132B1059ECC7232F9A78">>}]}, 34 | {pkg_hash_ext,[ 35 | {<<"base64url">>, <<"FAB09B20E3F5DB886725544CBCF875B8E73EC93363954EB8A1A9ED834AA8C1F9">>}, 36 | {<<"certifi">>, <<"EE68D85DF22E554040CDB4BE100F33873AC6051387BAF6A8F6CE82272340FF1C">>}, 37 | {<<"erlsom">>, <<"7965485494C5844DD127656AC40F141AADFA174839EC1BE1074E7EDF5B4239EB">>}, 38 | {<<"hackney">>, <<"FE9094E5F1A2A2C0A7D10918FEE36BFEC0EC2A979994CFF8CFE8058CD9AF38E3">>}, 39 | {<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>}, 40 | {<<"jsx">>, <<"0C5CC8FDC11B53CC25CF65AC6705AD39E54ECC56D1C22E4ADB8F5A53FB9427F3">>}, 41 | {<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>}, 42 | {<<"mimerl">>, <<"A1E15A50D1887217DE95F0B9B0793E32853F7C258A5CD227650889B38839FE9D">>}, 43 | {<<"parse_trans">>, <<"620A406CE75DADA827B82E453C19CF06776BE266F5A67CFF34E1EF2CBB60E49A">>}, 44 | {<<"ssl_verify_fun">>, <<"FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8">>}, 45 | {<<"unicode_util_compat">>, <<"25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521">>}]} 46 | ]. 47 | -------------------------------------------------------------------------------- /src/oauth2c.app.src: -------------------------------------------------------------------------------- 1 | %% ---------------------------------------------------------------------------- 2 | %% 3 | %% oauth2: Erlang OAuth 2.0 Client 4 | %% 5 | %% Copyright (c) 2012 KIVRA 6 | %% 7 | %% Permission is hereby granted, free of charge, to any person obtaining a 8 | %% copy of this software and associated documentation files (the "Software"), 9 | %% to deal in the Software without restriction, including without limitation 10 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 11 | %% and/or sell copies of the Software, and to permit persons to whom the 12 | %% Software is furnished to do so, subject to the following conditions: 13 | %% 14 | %% The above copyright notice and this permission notice shall be included in 15 | %% all copies or substantial portions of the Software. 16 | %% 17 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | %% DEALINGS IN THE SOFTWARE. 24 | %% 25 | %% ---------------------------------------------------------------------------- 26 | 27 | {application, oauth2c, 28 | [ 29 | {description, "Erlang OAuth2 Client"}, 30 | {vsn, git}, 31 | {registered, []}, 32 | {mod, {oauth2c_app, []}}, 33 | {applications, [ 34 | kernel, 35 | stdlib, 36 | inets, 37 | jsx, 38 | jose, 39 | restc 40 | ]}, 41 | {env, []}, 42 | {modules, []}, 43 | {maintainers, ["Kivra"]}, 44 | {licenses, ["MIT"]}, 45 | {links, [{"Github", "https://github.com/kivra/oauth2_client"}]} 46 | ]}. 47 | -------------------------------------------------------------------------------- /src/oauth2c.erl: -------------------------------------------------------------------------------- 1 | %% ---------------------------------------------------------------------------- 2 | %% 3 | %% oauth2: Erlang OAuth 2.0 Client 4 | %% 5 | %% Copyright (c) 2012-2016 KIVRA 6 | %% 7 | %% Permission is hereby granted, free of charge, to any person obtaining a 8 | %% copy of this software and associated documentation files (the "Software"), 9 | %% to deal in the Software without restriction, including without limitation 10 | %% the rights to use, copy, modify, merge, publish, distribute, sublicense, 11 | %% and/or sell copies of the Software, and to permit persons to whom the 12 | %% Software is furnished to do so, subject to the following conditions: 13 | %% 14 | %% The above copyright notice and this permission notice shall be included in 15 | %% all copies or substantial portions of the Software. 16 | %% 17 | %% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | %% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | %% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | %% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | %% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | %% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | %% DEALINGS IN THE SOFTWARE. 24 | %% 25 | %% ---------------------------------------------------------------------------- 26 | 27 | -module(oauth2c). 28 | 29 | -export([from_service_account_file/2]). 30 | -export([service_account_project_id/1]). 31 | -export([client/4]). 32 | -export([client/5]). 33 | 34 | -export([retrieve_access_token/4]). 35 | -export([retrieve_access_token/5]). 36 | -export([retrieve_access_token/6]). 37 | 38 | -export([request/3]). 39 | -export([request/4]). 40 | -export([request/5]). 41 | -export([request/6]). 42 | -export([request/7]). 43 | -export([request/8]). 44 | 45 | -define(DEFAULT_ENCODING, json). 46 | -define(TOKEN_CACHE_SERVER, oauth2c_token_cache). 47 | 48 | -include("oauth2c.hrl"). 49 | 50 | %%% API ======================================================================== 51 | 52 | -spec from_service_account_file(FilePath, Scope) -> client() | {error, '_'} when 53 | FilePath :: file:filename(), 54 | Scope :: binary(). 55 | from_service_account_file(FilePath, Scope) -> 56 | case file:read_file(FilePath) of 57 | {ok, Data} -> 58 | service_account_map(jsx:decode(Data, [return_maps, {labels, attempt_atom}]), Scope); 59 | {error, _} = E -> 60 | E 61 | end. 62 | 63 | service_account_project_id(#client{grant_type = <<"service_account">>, service = SA}) -> 64 | SA#service_account.project_id. 65 | 66 | service_account_map(Map, Scope) -> 67 | #{ private_key := PemPrivKey 68 | , project_id := ProjectId 69 | , auth_uri := _AuthUri 70 | , token_uri := TokenUri 71 | , client_email := ClientEmail 72 | } = Map, 73 | SA = #service_account{ private_key = PemPrivKey 74 | , project_id = ProjectId 75 | , iss = ClientEmail 76 | , aud = TokenUri 77 | }, 78 | #client{ grant_type = <<"service_account">> 79 | , id = ClientEmail 80 | , auth_url = TokenUri 81 | , service = SA 82 | , scope = Scope }. 83 | 84 | -spec client(Type, URL, ID, Secret) -> client() when 85 | Type :: at_type(), 86 | URL :: url(), 87 | ID :: binary(), 88 | Secret :: binary(). 89 | client(Type, URL, ID, Secret) -> 90 | client(Type, URL, ID, Secret, undefined). 91 | 92 | -spec client(Type, URL, ID, Secret, Scope) -> client() when 93 | Type :: at_type(), 94 | URL :: url(), 95 | ID :: binary(), 96 | Secret :: binary(), 97 | Scope :: binary() | undefined. 98 | client(Type, URL, ID, Secret, Scope) -> 99 | #client{ grant_type = Type 100 | , auth_url = URL 101 | , id = ID 102 | , secret = Secret 103 | , scope = Scope 104 | }. 105 | 106 | -spec retrieve_access_token(Type, URL, ID, Secret) -> 107 | {ok, Headers::headers(), client()} | {error, Reason :: binary()} when 108 | Type :: at_type(), 109 | URL :: url(), 110 | ID :: binary(), 111 | Secret :: binary(). 112 | retrieve_access_token(Type, Url, ID, Secret) -> 113 | retrieve_access_token(Type, Url, ID, Secret, undefined). 114 | 115 | -spec retrieve_access_token(Type, URL, ID, Secret, Scope) -> 116 | {ok, Headers::headers(), client()} | {error, Reason :: binary()} when 117 | Type :: at_type(), 118 | URL :: url(), 119 | ID :: binary(), 120 | Secret :: binary(), 121 | Scope :: binary() | undefined. 122 | retrieve_access_token(Type, Url, ID, Secret, Scope) -> 123 | retrieve_access_token(Type, Url, ID, Secret, Scope, []). 124 | 125 | -spec retrieve_access_token(Type, URL, ID, Secret, Scope, Options) -> 126 | {ok, Headers::headers(), client()} | {error, Reason :: binary()} when 127 | Type :: at_type(), 128 | URL :: url(), 129 | ID :: binary(), 130 | Secret :: binary(), 131 | Scope :: binary() | undefined, 132 | Options :: options(). 133 | retrieve_access_token(Type, Url, ID, Secret, Scope, Options) -> 134 | Client = #client{ grant_type = Type 135 | , auth_url = Url 136 | , id = ID 137 | , secret = Secret 138 | , scope = Scope 139 | }, 140 | do_retrieve_access_token(Client, Options). 141 | 142 | -spec request(Method, Url, Client) -> Response::response() when 143 | Method :: method(), 144 | Url :: url(), 145 | Client :: client(). 146 | request(Method, Url, Client) -> 147 | request(Method, ?DEFAULT_ENCODING, Url, [], [], [], Client). 148 | 149 | -spec request(Method, Url, Expect, Client) -> Response::response() when 150 | Method :: method(), 151 | Url :: url(), 152 | Expect :: status_codes(), 153 | Client :: client(). 154 | request(Method, Url, Expect, Client) -> 155 | request(Method, ?DEFAULT_ENCODING, Url, Expect, [], [], Client). 156 | 157 | -spec request(Method, Type, Url, Expect, Client) -> Response::response() when 158 | Method :: method(), 159 | Type :: content_type(), 160 | Url :: url(), 161 | Expect :: status_codes(), 162 | Client :: client(). 163 | request(Method, Type, Url, Expect, Client) -> 164 | request(Method, Type, Url, Expect, [], [], Client). 165 | 166 | -spec request(Method, Type, Url, Expect, Headers, Client) -> 167 | Response::response() when 168 | Method :: method(), 169 | Type :: content_type(), 170 | Url :: url(), 171 | Expect :: status_codes(), 172 | Headers :: headers(), 173 | Client :: client(). 174 | request(Method, Type, Url, Expect, Headers, Client) -> 175 | request(Method, Type, Url, Expect, Headers, [], Client). 176 | 177 | -spec request(Method, Type, Url, Expect, Headers, Body, Client) -> 178 | Response::response() when 179 | Method :: method(), 180 | Type :: content_type(), 181 | Url :: url(), 182 | Expect :: status_codes(), 183 | Headers :: headers(), 184 | Body :: body(), 185 | Client :: client(). 186 | request(Method, Type, Url, Expect, Headers, Body, Client) -> 187 | request(Method, Type, Url, Expect, Headers, Body, [], Client). 188 | 189 | -spec request(Method, Type, Url, Expect, Headers, Body, Options, Client) -> 190 | Response::response() when 191 | Method :: method(), 192 | Type :: content_type(), 193 | Url :: url(), 194 | Expect :: status_codes(), 195 | Headers :: headers(), 196 | Body :: body(), 197 | Options :: options(), 198 | Client :: client(). 199 | 200 | request(Method, Type, Url, Expect, Headers, Body, Options, Client0) -> 201 | Client1 = ensure_client_has_access_token(Client0, Options), 202 | case do_request(Method,Type,Url,Expect,Headers,Body,Options,Client1) of 203 | {{_, 401, _, _}, Client2} -> 204 | {ok, Client3} = get_access_token(Client2#client{access_token = undefined}, 205 | [force_revalidate | Options]), 206 | do_request(Method, Type, Url, Expect, Headers, Body, Options, Client3); 207 | Result -> Result 208 | end. 209 | 210 | %%% INTERNAL =================================================================== 211 | 212 | ensure_client_has_access_token(Client0, Options) -> 213 | case Client0 of 214 | #client{access_token = undefined} -> 215 | {ok, Client} = get_access_token(Client0, Options), 216 | Client; 217 | _ -> 218 | Client0 219 | end. 220 | 221 | do_retrieve_access_token(Client, Opts0) -> 222 | Opts = Opts0 -- [return_maps], %% Make sure we get a proplist 223 | #{headers := RequestHeaders, 224 | body := RequestBody} = prepare_token_request(Client, Opts), 225 | case restc:request(post, percent, Client#client.auth_url, 226 | [200], RequestHeaders, RequestBody, Opts) 227 | of 228 | {ok, _, Headers, Body} -> 229 | AccessToken = proplists:get_value(<<"access_token">>, Body), 230 | TokenType = proplists:get_value(<<"token_type">>, Body, ""), 231 | ExpireTime = 232 | case proplists:get_value(<<"expires_in">>, Body) of 233 | undefined -> undefined; 234 | ExpiresIn -> erlang:system_time(second) + ExpiresIn 235 | end, 236 | RefreshToken = proplists:get_value(<<"refresh_token">>, 237 | Body, 238 | Client#client.refresh_token), 239 | Result = #client{ grant_type = Client#client.grant_type 240 | , auth_url = Client#client.auth_url 241 | , access_token = AccessToken 242 | , refresh_token = RefreshToken 243 | , token_type = get_token_type(TokenType) 244 | , id = Client#client.id 245 | , secret = Client#client.secret 246 | , scope = Client#client.scope 247 | , service = Client#client.service 248 | , expire_time = ExpireTime 249 | }, 250 | {ok, Headers, Result}; 251 | {error, _, _, Reason} -> 252 | {error, Reason}; 253 | {error, Reason} -> 254 | {error, Reason} 255 | end. 256 | 257 | prepare_token_request(Client, Opts) -> 258 | BaseRequest = base_request(Client), 259 | Request0 = add_client(BaseRequest, Client, Opts), 260 | add_fields(Request0, Client). 261 | 262 | %% https://developers.google.com/identity/protocols/oauth2/service-account#jwt-auth 263 | base_request(#client{grant_type = <<"service_account">>}=C) -> 264 | GrantType = <<"urn:ietf:params:oauth:grant-type:jwt-bearer">>, 265 | {_, JWT} = service_account_jwt(C), 266 | #{ headers => [] 267 | , body => [ {<<"grant_type">>, GrantType} 268 | , {<<"assertion">>, JWT} 269 | ] 270 | }; 271 | base_request(#client{grant_type = <<"azure_client_credentials">>}) -> 272 | #{headers => [], body => [{<<"grant_type">>, <<"client_credentials">>}]}; 273 | base_request(#client{grant_type = GrantType}) -> 274 | #{headers => [], body => [{<<"grant_type">>, GrantType}]}. 275 | 276 | service_account_jwt(#client{service = SA}=C) when is_record(SA, service_account) -> 277 | JWT = begin 278 | IAT = epoch(), 279 | EXP = IAT + 1800, 280 | jose_jwt:from(#{ iss => SA#service_account.iss 281 | , scope => C#client.scope 282 | , aud => SA#service_account.aud 283 | , exp => EXP 284 | , iat => IAT 285 | }) 286 | end, 287 | JWK = jose_jwk:from_pem(SA#service_account.private_key), 288 | JWS = jose_jws:from(#{<<"alg">> => <<"RS256">>, <<"typ">> => <<"JWT">> }), 289 | jose_jws:compact(jose_jwt:sign(JWK, JWS, JWT)). 290 | 291 | epoch() -> 292 | {MegaSecs, Secs, _MicroSecs} = os:timestamp(), 293 | MegaSecs * 1000000 + Secs. 294 | 295 | add_client(Request0, Client, Opts) -> 296 | #client{id = Id, secret = Secret} = Client, 297 | case 298 | {Client#client.grant_type =:= <<"service_account">>, 299 | Client#client.grant_type =:= <<"password">>, 300 | Client#client.grant_type =:= <<"azure_client_credentials">> orelse 301 | proplists:get_value(credentials_in_body, Opts, false)} 302 | of 303 | {false, false, false} -> 304 | #{headers := Headers0} = Request0, 305 | Auth = base64:encode(<>), 306 | Headers = [{<<"Authorization">>, <<"Basic ", Auth/binary>>} 307 | | Headers0], 308 | Request0#{headers => Headers}; 309 | {false, false, true} -> 310 | #{body := Body} = Request0, 311 | Request0#{body => [{<<"client_id">>, Id}, 312 | {<<"client_secret">>, Secret} 313 | | Body]}; 314 | %% This clause is to still support password grant "as is" but 315 | %% in the future this should be changed in order to support 316 | %% client authentication in the password grant. Right now we 317 | %% are assuming that if the grant is password then the client is public 318 | %% which is not a fair assumption. 319 | {false, true, _} -> 320 | #{body := Body} = Request0, 321 | Request0#{body => [{<<"username">>, Id}, 322 | {<<"password">>, Secret} | Body]}; 323 | {true, _, _} -> 324 | Request0 325 | end. 326 | 327 | add_fields(Request, #client{grant_type = <<"service_account">>}) -> 328 | Request; 329 | add_fields(Request, #client{scope=undefined}) -> 330 | Request; 331 | add_fields(Request, #client{grant_type = <<"azure_client_credentials">>, 332 | scope = Scope}) -> 333 | #{body := Body} = Request, 334 | Request#{body => [{<<"resource">>, Scope} | Body]}; 335 | add_fields(Request, #client{scope = Scope}) -> 336 | #{body := Body} = Request, 337 | Request#{body => [{<<"scope">>, Scope} | Body]}. 338 | 339 | -spec get_token_type(binary()) -> token_type(). 340 | get_token_type(Type) -> 341 | get_str_token_type(string:to_lower(binary_to_list(Type))). 342 | 343 | -spec get_str_token_type(string()) -> token_type(). 344 | get_str_token_type("bearer") -> bearer; 345 | get_str_token_type(_Else) -> unsupported. 346 | 347 | do_request(Method, Type, Url, Expect, Headers0, Body, Options, Client) -> 348 | Headers = add_auth_header(Headers0, Client), 349 | {restc:request(Method, Type, Url, Expect, Headers, Body, Options), Client}. 350 | 351 | add_auth_header(Headers, #client{grant_type = <<"azure_client_credentials">>, 352 | access_token = AccessToken}) -> 353 | AH = {<<"Authorization">>, <<"bearer ", AccessToken/binary>>}, 354 | [AH | proplists:delete(<<"Authorization">>, Headers)]; 355 | add_auth_header(Headers, #client{grant_type = <<"service_account">>, 356 | token_type = bearer, 357 | access_token = AccessToken}) -> 358 | AH = {<<"Authorization">>, <<"Bearer ", AccessToken/binary>>}, 359 | [AH | proplists:delete(<<"Authorization">>, Headers)]; 360 | add_auth_header(Headers, #client{token_type = bearer, 361 | access_token = AccessToken}) -> 362 | AH = {<<"Authorization">>, <<"bearer ", AccessToken/binary>>}, 363 | [AH | proplists:delete(<<"Authorization">>, Headers)]; 364 | add_auth_header(Headers, #client{access_token = AccessToken}) -> 365 | AH = {<<"Authorization">>, <<"token ", AccessToken/binary>>}, 366 | [AH | proplists:delete(<<"Authorization">>, Headers)]. 367 | 368 | retrieve_access_token_fun(Client0, Options) -> 369 | fun() -> 370 | case do_retrieve_access_token(Client0, Options) of 371 | {ok, _Headers, Client} -> {ok, Client, Client#client.expire_time}; 372 | {error, Reason} -> {error, Reason} 373 | end 374 | end. 375 | 376 | get_access_token(#client{expire_time = ExpireTime} = Client0, Options) -> 377 | case {proplists:get_value(cache_token, Options, false), 378 | proplists:get_value(force_revalidate, Options, false)} 379 | of 380 | {false, _} -> 381 | {ok, _Headers, Client} = do_retrieve_access_token(Client0, Options), 382 | {ok, Client}; 383 | {true, false} -> 384 | Key = hash_client(Client0), 385 | case oauth2c_token_cache:get(Key) of 386 | {error, not_found} -> 387 | RevalidateFun = retrieve_access_token_fun(Client0, Options), 388 | oauth2c_token_cache:set_and_get(Key, RevalidateFun); 389 | {ok, Client} -> 390 | {ok, Client} 391 | end; 392 | {true, true} -> 393 | Key = hash_client(Client0), 394 | RevalidateFun = retrieve_access_token_fun(Client0, Options), 395 | oauth2c_token_cache:set_and_get(Key, RevalidateFun, ExpireTime) 396 | end. 397 | 398 | hash_client(#client{grant_type = Type, 399 | auth_url = AuthUrl, 400 | id = ID, 401 | secret = Secret, 402 | scope = Scope}) -> 403 | erlang:phash2({Type, AuthUrl, ID, Secret, Scope}). 404 | 405 | %%%_ * Tests ------------------------------------------------------- 406 | 407 | -ifdef(TEST). 408 | -include_lib("eunit/include/eunit.hrl"). 409 | 410 | -endif. 411 | 412 | %%%_* Emacs ============================================================ 413 | %%% Local Variables: 414 | %%% allout-layout: t 415 | %%% erlang-indent-level: 2 416 | %%% End: 417 | -------------------------------------------------------------------------------- /src/oauth2c_app.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %% @doc oauth2c application callback 3 | %% @end 4 | %%%------------------------------------------------------------------- 5 | 6 | -module(oauth2c_app). 7 | 8 | -behaviour(application). 9 | 10 | -export([start/2, stop/1]). 11 | 12 | start(_StartType, _StartArgs) -> 13 | oauth2c_sup:start_link(). 14 | 15 | stop(_State) -> 16 | ok. 17 | -------------------------------------------------------------------------------- /src/oauth2c_sup.erl: -------------------------------------------------------------------------------- 1 | %%%------------------------------------------------------------------- 2 | %% @doc Top level supervisor of oauth2c. 3 | %% @end 4 | %%%------------------------------------------------------------------- 5 | 6 | %%%_* Module declaration =============================================== 7 | -module(oauth2c_sup). 8 | -behaviour(supervisor). 9 | 10 | %%%_* Exports ========================================================== 11 | -export([start_link/0]). 12 | -export([init/1]). 13 | 14 | %%%_* Code ============================================================= 15 | %%%_ * API ------------------------------------------------------------- 16 | 17 | start_link() -> 18 | supervisor:start_link({local, ?MODULE}, ?MODULE, []). 19 | 20 | init([]) -> 21 | Strategy = #{strategy => one_for_one, 22 | intensity => 2, 23 | period => 60}, 24 | ChildSpecs = [#{id => oauth2c_token_cache, 25 | start => {oauth2c_token_cache, start_link, []}, 26 | restart => permanent, 27 | shutdown => 5000, 28 | type => worker, 29 | modules => [oauth2c_token_cache] 30 | }], 31 | {ok, {Strategy, ChildSpecs}}. 32 | -------------------------------------------------------------------------------- /src/oauth2c_token_cache.erl: -------------------------------------------------------------------------------- 1 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 2 | %%% @doc OAuth2 Authentication Token Cache - This gen_server implements a 3 | %%% simple caching mechanism for authentication tokens. 4 | %%% @end 5 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 6 | 7 | %%%_* Module declaration =============================================== 8 | -module(oauth2c_token_cache). 9 | -behaviour(gen_server). 10 | 11 | -elvis([{elvis_style, state_record_and_type, disable}]). 12 | 13 | %%%_* Exports ========================================================== 14 | 15 | -export([start/0]). 16 | -export([start/1]). 17 | -export([start_link/0]). 18 | -export([start_link/1]). 19 | -export([get/1]). 20 | -export([set_and_get/2]). 21 | -export([set_and_get/3]). 22 | 23 | %% gen_server 24 | -export([init/1]). 25 | -export([handle_call/3]). 26 | -export([handle_cast/2]). 27 | -export([clear/0]). 28 | 29 | %%%_* Macros ========================================================= 30 | 31 | -define(DEFAULT_TTL, 300). % Default cache entry TTL in seconds. 32 | -define(SERVER, ?MODULE). 33 | -define(TOKEN_CACHE_ID, token_cache_id). 34 | 35 | %%%_* Code ============================================================= 36 | %%%_ * Types ----------------------------------------------------------- 37 | %%%_ * API ------------------------------------------------------------- 38 | 39 | -spec start() -> {atom(), pid()}. 40 | start() -> 41 | start(?DEFAULT_TTL). 42 | -spec start(non_neg_integer()) -> {atom(), pid()}. 43 | start(DefaultTTL) -> 44 | gen_server:start({local, ?SERVER}, ?SERVER, 45 | #{default_ttl => DefaultTTL}, []). 46 | 47 | -spec start_link() -> {atom(), pid()}. 48 | start_link() -> 49 | start_link(?DEFAULT_TTL). 50 | -spec start_link(non_neg_integer()) -> {atom(), pid()}. 51 | start_link(DefaultTTL) -> 52 | gen_server:start_link({local, ?SERVER}, ?SERVER, 53 | #{default_ttl => DefaultTTL}, []). 54 | 55 | -spec get(integer()) -> {error, atom()} | {ok, term()}. 56 | get(Key) -> 57 | get_token(Key). 58 | 59 | -spec set_and_get(Key, LazyValue) -> Value | Error when 60 | Key :: integer(), 61 | LazyValue :: fun(() -> Value | Error), 62 | Value :: {ok, term()}, 63 | Error :: {error, binary()}. 64 | set_and_get(Key, LazyValue) -> 65 | set_and_get(Key, LazyValue, undefined). 66 | 67 | -spec set_and_get(Key, LazyValue, CurrenTokenExpiryTime) -> Value | Error when 68 | Key :: integer(), 69 | LazyValue :: fun(() -> Value | Error), 70 | CurrenTokenExpiryTime :: integer() | undefined, 71 | Value :: {ok, term()}, 72 | Error :: {error, atom()}. 73 | set_and_get(Key, LazyValue, CurrenTokenExpiryTime) -> 74 | gen_server:call(?SERVER, {set_and_get, 75 | Key, 76 | LazyValue, 77 | CurrenTokenExpiryTime}). 78 | 79 | -spec clear() -> true. 80 | clear() -> 81 | ets:delete_all_objects(?TOKEN_CACHE_ID). 82 | 83 | %%%_ * gen_server callbacks -------------------------------------------- 84 | 85 | init(State) -> 86 | EtsOpts = [set, public, named_table, {read_concurrency, true}], 87 | ets:new(?TOKEN_CACHE_ID, EtsOpts), 88 | {ok, State}. 89 | 90 | handle_call({set_and_get, Key, LazyValue, 91 | CurrenTokenExpiryTime}, _From, 92 | State = #{default_ttl := DefaultTTL}) -> 93 | % CurrenTokenExpiryTime is used to solve a race-condition 94 | % that occurs when multiple processes are trying to 95 | % replace an old token (i.e. the new token has a larger 96 | % expiry time than the old token). 97 | case get_token(Key, CurrenTokenExpiryTime) of 98 | {ok, Result} -> 99 | {reply, {ok, Result}, State}; 100 | {error, not_found} -> 101 | case LazyValue() of 102 | {ok, Result, ExpireTime} -> 103 | ExpiryTime = get_expire_time(ExpireTime, DefaultTTL), 104 | ets:insert(?TOKEN_CACHE_ID, {Key, Result, ExpiryTime}), 105 | {reply, {ok, Result}, State}; 106 | {error, Reason} -> {reply, {error, Reason}, State} 107 | end 108 | end. 109 | 110 | handle_cast(_, State) -> {noreply, State}. 111 | 112 | %%%_ * Private functions ----------------------------------------------- 113 | 114 | get_token(Key) -> 115 | get_token(Key, undefined). 116 | get_token(Key, ExpiryTimeLowerLimit) -> 117 | Now = erlang:system_time(second), 118 | case ets:lookup(?TOKEN_CACHE_ID, Key) of 119 | % Only return cache entry if 120 | % (1) It has not expired 121 | % (2) Its expiry time is greater than ExpiryTimeLowerLimit 122 | [{Key, Result, ExpiryTime}] when ExpiryTime > Now 123 | andalso 124 | (ExpiryTimeLowerLimit =:= undefined 125 | orelse 126 | ExpiryTime > ExpiryTimeLowerLimit) -> 127 | {ok, Result}; 128 | _ -> 129 | {error, not_found} 130 | end. 131 | 132 | get_expire_time(undefined, DefaultTTL) -> 133 | erlang:system_time(second) + DefaultTTL; 134 | get_expire_time(ExpireTime, _DefaultTTL) -> 135 | ExpireTime. 136 | 137 | %%%_ * Tests ------------------------------------------------------- 138 | 139 | -ifdef(TEST). 140 | -include_lib("eunit/include/eunit.hrl"). 141 | 142 | get_expires_in_test_() -> 143 | [fun() -> 144 | {T, Default} = Input, 145 | Actual = get_expire_time(T, Default), 146 | ?assertEqual(Expected, Actual) 147 | end 148 | || {Input, Expected} <- [{{1, 100}, 1}] 149 | ]. 150 | 151 | -endif. 152 | -------------------------------------------------------------------------------- /test/oauth2c_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(oauth2c_SUITE). 2 | -compile([export_all]). 3 | 4 | -include_lib("common_test/include/ct.hrl"). 5 | -include_lib("stdlib/include/assert.hrl"). 6 | -include("oauth2c.hrl"). 7 | 8 | -define(AUTH_URL, <<"https://authurl.com">>). 9 | -define(INVALID_TOKEN_AUTH_URL, <<"https://invalidauthurl.com">>). 10 | -define(REQUEST_URL, <<"https://requesturl.com">>). 11 | -define(REQUEST_URL_401, <<"https://requesturl401.com">>). 12 | -define(CLIENT_CREDENTIALS_GRANT, <<"client_credentials">>). 13 | -define(VALID_TOKEN, <<"iamanaccesstoken">>). 14 | -define(HEADERS(AccessToken), 15 | [{<<"Authorization">>, <<"bearer ", AccessToken/binary>>}]). 16 | 17 | -define(GET_BODY, [{<<"a">>, <<"b">>}]). 18 | 19 | groups() -> []. 20 | 21 | all() -> [ client_credentials_in_body 22 | , client_credentials_in_header 23 | , retrieve_access_token 24 | , fetch_access_token_on_request 25 | , fetch_access_token_on_request 26 | , fetch_new_token_on_401 27 | , retrieve_cached_access_token 28 | , retrieve_cached_expired_access_token 29 | , retrieve_cached_token_burst 30 | , retrieve_cached_token_burst_with_expire 31 | , retrieve_cached_token_on_401 32 | , retrieve_cached_token_on_401_burst 33 | ]. 34 | 35 | init_per_suite(Config) -> 36 | {ok, Pid} = oauth2c_token_cache:start(1), 37 | [{pid, Pid}|Config]. 38 | end_per_suite(Config) -> 39 | {pid, Pid} = proplists:lookup(pid, Config), 40 | exit(Pid, shutdown), 41 | ok. 42 | 43 | init_per_testcase(retrieve_cached_token_on_401_burst, Config) -> 44 | mock_http_request_401_burst(), 45 | Config; 46 | init_per_testcase(retrieve_cached_token_on_401, Config) -> 47 | mock_http_request_401(), 48 | Config; 49 | init_per_testcase(_TestCase, Config) -> 50 | mock_http_requests(), 51 | Config. 52 | end_per_testcase(_TestCase, Config) -> 53 | meck:unload([restc]), 54 | oauth2c_token_cache:clear(), 55 | Config. 56 | 57 | client_credentials_in_body(_Config) -> 58 | oauth2c:retrieve_access_token(?CLIENT_CREDENTIALS_GRANT, 59 | ?AUTH_URL, 60 | <<"ID">>, 61 | <<"SECRET">>, 62 | undefined, 63 | [credentials_in_body]), 64 | ?assert(meck:called(restc, request, [post, 65 | percent, 66 | ?AUTH_URL, 67 | '_', 68 | [], %% empty headers 69 | ['_', '_', '_'], %% grant_type + creds 70 | '_'])). 71 | 72 | client_credentials_in_header(_Config) -> 73 | oauth2c:retrieve_access_token(?CLIENT_CREDENTIALS_GRANT, 74 | ?AUTH_URL, 75 | <<"ID">>, 76 | <<"SECRET">>), 77 | ?assert(meck:called(restc, request, [post, 78 | percent, 79 | ?AUTH_URL, 80 | '_', 81 | ['_'], %% credentials 82 | ['_'], %% grant_type 83 | '_'])). 84 | 85 | retrieve_access_token(_Config) -> 86 | Response = oauth2c:retrieve_access_token(?CLIENT_CREDENTIALS_GRANT, 87 | ?AUTH_URL, 88 | <<"ID">>, 89 | <<"SECRET">>), 90 | ?assertMatch({ok, _, _}, Response). 91 | 92 | retrieve_cached_access_token(_Config) -> 93 | Client = client(?AUTH_URL), 94 | oauth2c:request(get, json, ?REQUEST_URL, [], [], [], [cache_token], Client), 95 | oauth2c:request(get, json, ?REQUEST_URL, [], [], [], [cache_token], Client), 96 | ?assertEqual(1, meck:num_calls(restc, request, 97 | [ post, percent, 98 | ?AUTH_URL, '_', '_', '_', '_' 99 | ])). 100 | 101 | retrieve_cached_expired_access_token(_Config) -> 102 | Client = client(?AUTH_URL), 103 | oauth2c:request(get, json, ?REQUEST_URL, [], [], [], [cache_token], Client), 104 | % TTL is 1000ms for a cached entry, hence sleeping for 1050ms should 105 | % make the cached entry invalid. 106 | timer:sleep(1050), 107 | oauth2c:request(get, json, ?REQUEST_URL, [], [], [], [cache_token], Client), 108 | ?assertEqual(2, meck:num_calls(restc, request, 109 | [ post, percent, 110 | ?AUTH_URL, '_', '_', '_', '_' 111 | ])). 112 | 113 | retrieve_cached_token_burst(_Config) -> 114 | % The cache server should be able to handle multiple concurrent requests 115 | % and only perform a single token request. 116 | Client0 = client(?AUTH_URL), 117 | N = 1000, 118 | Fun = fun() -> 119 | oauth2c:request(get, json, ?REQUEST_URL, [], [], [], 120 | [cache_token], Client0) 121 | end, 122 | process_flag(trap_exit, true), 123 | [spawn_link(Fun) || _ <- lists:seq(1, N)], 124 | [receive {'EXIT', _, _} -> ok end || _ <- lists:seq(1, N)], 125 | ?assertEqual(1, meck:num_calls(restc, request, 126 | [ post, percent, 127 | ?AUTH_URL, '_', '_', '_', '_' 128 | ])). 129 | 130 | retrieve_cached_token_burst_with_expire(_Config) -> 131 | Client0 = client(?AUTH_URL), 132 | N = 1000, 133 | Fun = 134 | fun(M) -> 135 | fun() -> 136 | case M > 50 of 137 | true -> 138 | % Force oauth2c:request to look inside of the cache 139 | Client = Client0#client{access_token = undefined}, 140 | oauth2c:request(get, json, ?REQUEST_URL, [], [], [], 141 | [cache_token], Client); 142 | _ -> 143 | oauth2c:request(get, json, ?REQUEST_URL, [], [], [], 144 | [cache_token], Client0) 145 | end 146 | end 147 | end, 148 | process_flag(trap_exit, true), 149 | [case Num of 150 | 51 -> timer:sleep(1050), spawn_link(Fun(Num)); 151 | _ -> spawn_link(Fun(Num)) 152 | end || Num <- lists:seq(1, N)], 153 | [receive {'EXIT', _, _} -> ok end || _ <- lists:seq(1, N - 1)], 154 | ?assertEqual(2, meck:num_calls(restc, request, 155 | [ post, percent, 156 | ?AUTH_URL, '_', '_', '_', '_' 157 | ])). 158 | 159 | retrieve_cached_token_on_401(_Config) -> 160 | Client0 = client(?AUTH_URL), 161 | Response1 = oauth2c:request(get, json, 162 | ?REQUEST_URL, [], [], [], [cache_token], Client0), 163 | ?assertMatch({{ok, 200, _, _}, _}, Response1), 164 | {_, Client1} = Response1, 165 | % Second call to request will return 401 and 166 | % an automatic refresh av token should be triggered 167 | Response2 = oauth2c:request(get, json, 168 | ?REQUEST_URL, [], [], [], [cache_token], Client1), 169 | ?assertMatch({{ok, 401, _, _}, _}, Response2), 170 | ?assertEqual(2, meck:num_calls(restc, request, 171 | [ post, percent, 172 | ?AUTH_URL, '_', '_', '_', '_' 173 | ])), 174 | ?assertMatch({{ok, 200, _, _}, _}, Response1), 175 | {_, Client1} = Response1, 176 | {_, Client2} = Response2, 177 | ?assert(Client1#client.expire_time < Client2#client.expire_time). 178 | 179 | retrieve_cached_token_on_401_burst(_Config) -> 180 | Client = client(?AUTH_URL), 181 | % First call to request will return a access token with expires_in X, 182 | % and this token will be cached. 183 | {{ok, 200, _, _}, Client1} = oauth2c:request(get, json, 184 | ?REQUEST_URL, [], [], [], [cache_token], Client), 185 | N = 10, 186 | 187 | % Subsequent calls to request will fail with 401, and 188 | % the access token will automatically be refreshed by all N 189 | % processes concurrently. However, only 1 of the N processes 190 | % should request a new access token and the other N - 1 should 191 | % use the token fetched by the one process. 192 | Fun = fun() -> 193 | {{ok, 401, _, _}, _} = oauth2c:request(get, json, 194 | ?REQUEST_URL, [], [], [], [cache_token], Client1) 195 | end, 196 | process_flag(trap_exit, true), 197 | [spawn_link(Fun) || _ <- lists:seq(1, N)], 198 | [receive {'EXIT', _, _} -> ok end || _ <- lists:seq(1, N - 1)], 199 | ?assertEqual(2, meck:num_calls(restc, request, 200 | [ post, percent, 201 | ?AUTH_URL, '_', '_', '_', '_' 202 | ])), 203 | ?assertEqual(N * 2 + 1, meck:num_calls(restc, request, 204 | [ get, json, 205 | ?REQUEST_URL, '_', '_', '_', '_' 206 | ])), 207 | % Perform a final call to request to get back the currently cached 208 | % token and make sure that it has indeed been updated by 1 of the N 209 | % processes, 210 | {{ok, 401, _, _}, Client2} = oauth2c:request(get, json, 211 | ?REQUEST_URL, [], [], [], [cache_token], Client1), 212 | ?assert(Client1#client.expire_time < Client2#client.expire_time). 213 | 214 | fetch_access_token_and_do_request(_Config) -> 215 | {ok, _, Client} = oauth2c:retrieve_access_token(?CLIENT_CREDENTIALS_GRANT, 216 | ?AUTH_URL, 217 | <<"ID">>, 218 | <<"SECRET">>), 219 | Response = oauth2c:request(get, json, ?REQUEST_URL, [], [], [], [], Client), 220 | ?assertMatch({{ok, 200, _, ?GET_BODY}, Client}, Response), 221 | ?assertNot(meck:called(restc, request, 222 | [post, percent, ?AUTH_URL, '_', '_', '_', '_'])). 223 | 224 | 225 | fetch_access_token_on_request(_Config) -> 226 | Client = oauth2c:client(?CLIENT_CREDENTIALS_GRANT, ?AUTH_URL, <<"ID">>, 227 | <<"SECRET">>), 228 | Response = oauth2c:request(get, json, ?REQUEST_URL, [], [], [], [], Client), 229 | ?assertMatch({{ok, 200, _, ?GET_BODY}, _}, Response), 230 | ?assert(meck:called(restc, request, 231 | [post, percent, ?AUTH_URL, '_', '_', '_', '_'])). 232 | 233 | fetch_new_token_on_401(_Config) -> 234 | {ok, _, Client} = oauth2c:retrieve_access_token(?CLIENT_CREDENTIALS_GRANT, 235 | ?INVALID_TOKEN_AUTH_URL, 236 | <<"ID">>, 237 | <<"SECRET">>), 238 | ?assert(1 =:= meck:num_calls(restc, request, 239 | [ post, percent, 240 | ?INVALID_TOKEN_AUTH_URL, '_', '_', '_', '_' 241 | ])), 242 | 243 | Response = oauth2c:request(get, json, ?REQUEST_URL, [], [], [], [], Client), 244 | ?assertMatch({{ok, 401, _, _}, Client}, Response), 245 | ?assert(2 =:= meck:num_calls(restc, request, 246 | [ post, percent, 247 | ?INVALID_TOKEN_AUTH_URL, '_', '_', '_', '_' 248 | ])). 249 | 250 | mock_http_requests() -> 251 | meck:expect(restc, request, 252 | fun(post, percent, ?AUTH_URL, [200], _, _, _) -> 253 | Body = [{<<"access_token">>, ?VALID_TOKEN}, 254 | {<<"expires_in">>, 1}, 255 | {<<"token_type">>, <<"bearer">>}], 256 | {ok, 200, [], Body}; 257 | (post, percent, ?INVALID_TOKEN_AUTH_URL, [200], _, _, _) -> 258 | Body = [{<<"access_token">>, <<"invalid">>}, 259 | {<<"token_type">>, <<"bearer">>}], 260 | {ok, 200, [], Body}; 261 | (get, json, ?REQUEST_URL_401, _, _, _, _) -> 262 | {ok, 401, [], []}; 263 | (get, json, _, _, Headers, _, _) -> 264 | ValidToken = ?HEADERS(?VALID_TOKEN), 265 | case Headers of 266 | ValidToken -> {ok, 200, [], [{<<"a">>, <<"b">>}]}; 267 | _ -> {ok, 401, [], []} 268 | end 269 | end). 270 | 271 | mock_http_request_401() -> 272 | meck:expect(restc, request, 273 | [ 274 | {[post, percent, ?AUTH_URL, [200], '_', '_', '_'], 275 | meck:seq([ 276 | {ok, 200, [], [{<<"access_token">>, <<"token1">>}, 277 | {<<"expires_in">>, 1}, 278 | {<<"token_type">>, <<"bearer">>}]}, 279 | {ok, 200, [], [{<<"access_token">>, <<"token2">>}, 280 | {<<"expires_in">>, 10}, 281 | {<<"token_type">>, <<"bearer">>}]} 282 | ]) 283 | }, 284 | {[get, json, ?REQUEST_URL, '_', '_', '_', '_'], 285 | meck:seq([ 286 | {ok, 200, [], [{<<"access_token">>, <<"invalid">>}, 287 | {<<"token_type">>, <<"bearer">>}]}, 288 | {ok, 401, [], [{<<"access_token">>, ?VALID_TOKEN}, 289 | {<<"expires_in">>, 1}, 290 | {<<"token_type">>, <<"bearer">>}]} 291 | ]) 292 | } 293 | ] 294 | ). 295 | 296 | mock_http_request_401_burst() -> 297 | meck:expect(restc, request, 298 | [ 299 | {[post, percent, ?AUTH_URL, [200], '_', '_', '_'], 300 | meck:seq([ 301 | {ok, 200, [], [{<<"access_token">>, ?VALID_TOKEN}, 302 | {<<"expires_in">>, 10}, 303 | {<<"token_type">>, <<"bearer">>}]}, 304 | {ok, 200, [], [{<<"access_token">>, ?VALID_TOKEN}, 305 | {<<"expires_in">>, 20}, 306 | {<<"token_type">>, <<"bearer">>}]} 307 | ]) 308 | }, 309 | {[get, json, ?REQUEST_URL, '_', '_', '_', '_'], 310 | meck:seq([ 311 | {ok, 200, [], []}, 312 | {ok, 401, [], []} 313 | ]) 314 | } 315 | ] 316 | ). 317 | 318 | 319 | 320 | client(Url) -> 321 | oauth2c:client( <<"client_credentials">> 322 | , Url 323 | , <<"client_id">> 324 | , <<"client_secret">>). 325 | 326 | %_* Editor =================================================================== 327 | % Local Variables: 328 | % allout-layout: t 329 | % erlang-indent-level: 2 330 | % End: 331 | -------------------------------------------------------------------------------- /test/oauth2c_token_cache_SUITE.erl: -------------------------------------------------------------------------------- 1 | -module(oauth2c_token_cache_SUITE). 2 | -compile([export_all]). 3 | 4 | -include_lib("common_test/include/ct.hrl"). 5 | -include_lib("stdlib/include/assert.hrl"). 6 | 7 | all() -> [ get_valid_token 8 | , get_expired_token 9 | , set_and_get_token 10 | , overwrite_and_get_token 11 | ]. 12 | 13 | init_per_suite(Config) -> 14 | {ok, Pid} = oauth2c_token_cache:start(), 15 | [{pid, Pid}|Config]. 16 | 17 | end_per_suite(Config) -> 18 | {pid, Pid} = proplists:lookup(pid, Config), 19 | exit(Pid, shutdown), 20 | ok. 21 | 22 | init_per_testcase(TestCase, Config) -> 23 | ?MODULE:TestCase({init, Config}). 24 | 25 | end_per_testcase(TestCase, Config) -> 26 | ?MODULE:TestCase({'end', Config}). 27 | 28 | get_valid_token({init, Config}) -> 29 | Config; 30 | get_valid_token({'end', Config}) -> 31 | oauth2c_token_cache:clear(), 32 | Config; 33 | get_valid_token(_Config) -> 34 | ExpiryTime = erlang:system_time(second) + 100, 35 | Client = client, 36 | LazyToken = 37 | fun() -> {ok, Client, ExpiryTime} end, 38 | oauth2c_token_cache:set_and_get(?FUNCTION_NAME, LazyToken), 39 | ?assertMatch({ok, Client}, oauth2c_token_cache:get(?FUNCTION_NAME)). 40 | 41 | set_and_get_token({init, Config}) -> Config; 42 | set_and_get_token({'end', Config}) -> 43 | oauth2c_token_cache:clear(), 44 | Config; 45 | set_and_get_token(_Config) -> 46 | ExpiryTime = erlang:system_time(second) + 100, 47 | Client = client, 48 | LazyToken = 49 | fun() -> {ok, Client, ExpiryTime} end, 50 | Res1 = oauth2c_token_cache:set_and_get(?FUNCTION_NAME, LazyToken), 51 | Res2 = oauth2c_token_cache:get(?FUNCTION_NAME), 52 | [ 53 | ?assertMatch({ok, Client}, Res1), 54 | ?assertMatch({ok, Client}, Res2) 55 | ]. 56 | 57 | overwrite_and_get_token({init, Config}) -> Config; 58 | overwrite_and_get_token({'end', Config}) -> 59 | oauth2c_token_cache:clear(), 60 | Config; 61 | overwrite_and_get_token(_Config) -> 62 | ExpiryTime1 = erlang:system_time(second) + 10, 63 | ExpiryTime2 = erlang:system_time(second) + 20, 64 | Client1 = client1, 65 | Client2 = client2, 66 | Client3 = client3, 67 | LazyToken1 = 68 | fun() -> {ok, Client1, ExpiryTime1} end, 69 | LazyToken2 = 70 | fun() -> {ok, Client2, ExpiryTime1} end, 71 | LazyToken3 = 72 | fun() -> {ok, Client3, ExpiryTime2} end, 73 | 74 | Res1 = oauth2c_token_cache:set_and_get(?FUNCTION_NAME, 75 | LazyToken1), 76 | Res2 = oauth2c_token_cache:set_and_get(?FUNCTION_NAME, 77 | LazyToken2), 78 | Res3 = oauth2c_token_cache:set_and_get(?FUNCTION_NAME, 79 | LazyToken3, ExpiryTime2), 80 | [ 81 | ?assertMatch({ok, Client1}, Res1), 82 | ?assertMatch({ok, Client1}, Res2), 83 | ?assertMatch({ok, Client3}, Res3) 84 | ]. 85 | 86 | get_expired_token({init, Config}) -> Config; 87 | get_expired_token({'end', Config}) -> 88 | oauth2c_token_cache:clear(), 89 | Config; 90 | get_expired_token(_Config) -> 91 | ExpiryTime = erlang:system_time(second) - 100, 92 | Client = client, 93 | LazyToken = 94 | fun() -> {ok, Client, ExpiryTime} end, 95 | oauth2c_token_cache:set_and_get(?FUNCTION_NAME, LazyToken), 96 | Res = oauth2c_token_cache:get(?FUNCTION_NAME), 97 | ?assertMatch({error, not_found}, Res). 98 | --------------------------------------------------------------------------------