├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── benchmarks ├── hs_benchmark.exs ├── jose_hs_benchmark.exs ├── pem_rs_benchmark.exs └── rs_benchmark.exs ├── config ├── .credo.exs ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── coveralls.json ├── guides ├── asymmetric_cryptography_signers.md ├── common_use_cases.md ├── configuration.md ├── custom_header_arguments.md ├── introduction.md ├── migration_from_1.md ├── signers.md └── testing.md ├── lib ├── joken.ex └── joken │ ├── claim.ex │ ├── config.ex │ ├── current_time.ex │ ├── error.ex │ ├── hooks.ex │ ├── hooks │ └── required_claims.ex │ └── signer.ex ├── mix.exs ├── mix.lock └── test ├── hooks └── required_claims_test.exs ├── joken_claim_test.exs ├── joken_config_test.exs ├── joken_hooks_test.exs ├── joken_signer_test.exs ├── joken_test.exs ├── support └── mock_current_time.ex ├── test_helper.exs └── use_config_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | locals_without_parens: [add_hook: 1, add_hook: 2], 4 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: ex_doc 10 | versions: 11 | - 0.23.0 12 | - 0.24.0 13 | - 0.24.1 14 | - dependency-name: dialyxir 15 | versions: 16 | - 1.1.0 17 | - dependency-name: excoveralls 18 | versions: 19 | - 0.13.4 20 | - 0.14.0 21 | - dependency-name: credo 22 | versions: 23 | - 1.5.4 24 | - 1.5.5 25 | - dependency-name: jose 26 | versions: 27 | - 1.11.1 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | check_duplicate_runs: 11 | name: Check for duplicate runs 12 | continue-on-error: true 13 | runs-on: ubuntu-latest 14 | outputs: 15 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 16 | steps: 17 | - id: skip_check 18 | uses: fkirc/skip-duplicate-actions@master 19 | with: 20 | concurrent_skipping: always 21 | cancel_others: true 22 | skip_after_successful_duplicate: true 23 | paths_ignore: '["**/README.md", "**/CHANGELOG.md", "**/LICENSE.txt"]' 24 | do_not_skip: '["pull_request"]' 25 | 26 | tests: 27 | name: Run tests 28 | 29 | needs: check_duplicate_runs 30 | if: ${{ needs.check_duplicate_runs.outputs.should_skip != 'true' }} 31 | 32 | env: 33 | FORCE_COLOR: 1 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | include: 39 | - elixir: "1.14.5" 40 | otp: "24.3.4.17" 41 | - elixir: "1.15.8-otp-25" 42 | otp: "25.3.2.13" 43 | - elixir: "1.16.3-otp-26" 44 | otp: "26.2.5.2" 45 | - elixir: "1.17.2-otp-27" 46 | otp: "27.0.1" 47 | 48 | runs-on: ubuntu-latest 49 | 50 | steps: 51 | - name: Checkout 52 | uses: actions/checkout@v2 53 | 54 | - name: Set up Elixir 55 | uses: erlef/setup-beam@v1 56 | with: 57 | elixir-version: ${{ matrix.elixir }} 58 | otp-version: ${{ matrix.otp }} 59 | 60 | - name: Restore deps and _build cache 61 | uses: actions/cache@v2 62 | with: 63 | path: | 64 | deps 65 | _build 66 | key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}-git-${{ github.sha }} 67 | restore-keys: | 68 | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 69 | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}- 70 | 71 | - name: Create dializer plts path 72 | run: mkdir -p priv/plts 73 | 74 | - name: Restore plts cache 75 | uses: actions/cache@v2 76 | with: 77 | path: priv/plts 78 | key: plts-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}-${{ github.sha }} 79 | restore-keys: | 80 | plts-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 81 | plts-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}- 82 | 83 | - name: Install dependencies 84 | run: mix deps.get --only test 85 | 86 | - name: Check source code format 87 | run: mix format --check-formatted 88 | 89 | - name: Perform source code static analysis 90 | run: mix credo --strict 91 | env: 92 | MIX_ENV: test 93 | 94 | - name: Remove compiled application files 95 | run: mix clean 96 | 97 | - name: Compile dependencies 98 | run: mix compile 99 | env: 100 | MIX_ENV: test 101 | 102 | - name: Compile & lint dependencies 103 | run: mix compile --warnings-as-errors 104 | env: 105 | MIX_ENV: test 106 | 107 | - name: Run tests 108 | run: mix coveralls.github --warnings-as-errors 109 | env: 110 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 111 | 112 | - name: Run dialyzer 113 | run: mix dialyzer 114 | env: 115 | MIX_ENV: test 116 | 117 | all_done: 118 | name: Tests done 119 | needs: tests 120 | 121 | runs-on: ubuntu-latest 122 | 123 | steps: 124 | - name: All done 125 | run: echo '+' 126 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | joken-*.tar 24 | 25 | # Temporary files for e.g. tests 26 | /tmp 27 | 28 | # Misc 29 | /bench/snapshots 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [2.6.2] - 2024-08-10 4 | 5 | ### Changed 6 | 7 | - Updated deps and CI versions 8 | - Fix deprecated CI badge (#427 - thanks to @kianmeng once again!) 9 | 10 | ## [2.6.1] - 2024-04-11 11 | 12 | ### Added 13 | 14 | - Allow PEM keys to specify a password (#392 thanks to @jeremyowensboggs) 15 | 16 | ### Fixed 17 | 18 | - Updated erlang JOSE to deal with CVE-2023-50966 (#418 thanks to @up2jj) 19 | 20 | ## [2.6.0] - 2023-01-29 21 | 22 | ### Changed 23 | 24 | - Allow EdDSA keys (thanks @Tarang) 25 | - Bump JOSE to 1.11.5 26 | - Fix `after_sign` spec 27 | - handle non binary hmac sha key error (thanks @alecostard) 28 | - Fixes `typ` header claim not being over writable 29 | - doc fixes all over 30 | - **raised Elixir version to 1.13** (JOSE raised it too so we follow) 31 | 32 | ## [2.5.0] - 2022-06-18 33 | 34 | ### Changed 35 | 36 | - Update release workflow (thanks @dolfinus) 37 | - Migrate GitHub Actions to erlef/setup-beam (thanks @kianmeng) 38 | - Fix typespecs (thanks @mustardnoise) 39 | - small refactor of CI 40 | - **raised Elixir version to 1.10** 41 | 42 | ## [2.4.1] - 2021-10-26 43 | 44 | ### Changed 45 | 46 | - fix: readme refs (thanks to @danferreira) 47 | - Fix generate_and_sign examples in guides' documentation (thanks to @f-francine) 48 | - Remove no_return from methods that can return (thanks to @jsmestad) 49 | - Fix typos (thanks to @kianmeng) 50 | 51 | ## [2.4.0] - 2021-08-15 52 | 53 | ### Changed 54 | 55 | - Major docs reviews and corrections (thanks to @kianmeng, @andreasknoepfle, Jon Forsyth, @fuelen) 56 | - Change of CI pipeline (thanks to @dolfinus) 57 | - Now uses Elixir 1.8 as minimum (and actually test the minimum version on CI) 58 | 59 | ### Fixed 60 | 61 | - Fixed arity of `Joken.Config.validate` (thanks to @blagh) 62 | - Compatibility with OTP 24.0 with JOSE update to 1.11.2 63 | 64 | ## [2.3.0] - 2020-09-27 65 | 66 | ### Changed 67 | 68 | - (@supersimple with @bryanjos) Update CHANGELOG.md (#257) 69 | - (@victorolinasc) chore: add public PEM only signer test 70 | - (@victorolinasc) chore: update deps 71 | - (@victorolinasc) Adding error handling (#277) 72 | - (@ideaMarcos) Update common_use_cases.md (#285) 73 | - (@victorolinasc) Clean up versions and compatibility with OTP 23 (#291) 74 | 75 | ### Fixed 76 | 77 | - (@woylie) fix type specs and doc (#266) 78 | 79 | ## [2.2.0] - 2019-11-08 80 | 81 | ### Added 82 | 83 | - (@bryanjos) Update .travis.yml to deploy to hex on tag (#232) 84 | - (@thefuture2029) Access current_time_adapter in runtime instead of compile time (#252) 85 | - (@victorolinasc) feat: add required claims hook (#250) 86 | 87 | ### Changed 88 | 89 | - Bump benchee from 0.14.0 to 1.0.1 90 | - Bump stream_data from 0.4.2 to 0.4.3 (#227) 91 | - Bump ex_doc from 0.19.3 to 0.20.2 (#230) 92 | - Bump dialyxir from 1.0.0-rc.4 to 1.0.0-rc.6 93 | - Bump credo from 1.0.2 to 1.0.5 94 | - Bump excoveralls from 0.10.5 to 0.11.1 (#233) 95 | - Bump ex_doc from 0.20.2 to 0.21.1 (#240) 96 | - Bump ex_doc from 0.21.1 to 0.21.2 (#246) 97 | - Bump excoveralls from 0.11.1 to 0.11.2 (#243) 98 | - Bump junit_formatter from 3.0.0 to 3.0.1 (#238) 99 | - Bump dialyxir from 1.0.0-rc.6 to 1.0.0-rc.7 (#248) 100 | - Bump credo from 1.0.5 to 1.1.5 (#253) 101 | - Bump excoveralls from 0.11.2 to 0.12.0 (#254) 102 | 103 | ### Fixed 104 | 105 | - (@llxff) Fix small typo in "Asymmetric cryptography signers" guide (#235) 106 | - (@polvalente) fix: treat improper token properly (#237) 107 | - (@chulkilee) Use short identifier from SPDX License List (#255) 108 | 109 | ## [2.1.0] - 2019-05-27 110 | 111 | ### Added 112 | 113 | - (@tgturner) Allow custom error messages on claim validation (#221) 114 | 115 | ### Changed 116 | 117 | - (@sgtpepper43) Get default signer at runtime (#212) 118 | - (@balena) Update to JOSE 1.9 and remove Jason dependency (#216) 119 | - (@victorolinasc) chore: deps update, docs update, removed unused application (#219) 120 | 121 | ### Fixed 122 | 123 | - (@maartenvanvliet) Plural time units are deprecated >= elixir1.8 (#213) 124 | - (@oo6) Fixed documentation (#218) 125 | - (@popo63301) fix typo (#220) 126 | - (@HeroicEric) Fix some typos in configuration guide (#222) 127 | 128 | ## [2.0.1] - 2019-02-17 129 | 130 | ### Changed 131 | 132 | - Get default signer at runtime (#212) @sgtpepper43 133 | - Update to JOSE 1.9 and remove Jason dependency (#216) @balena 134 | 135 | ### Fixed 136 | 137 | - Plural time units are deprecated >= elixir1.8 (#213) @maartenvanvliet 138 | - Fixed documentation (#218) @oo6 139 | 140 | ## [v2.0.0] - 2019-01-02 141 | 142 | This is a re-write with a focus on making a clearer API surface with less ambiguity and more future proof without breaking backwards compatibility once again. 143 | 144 | For changes on versions 1.x look on the v1.5 branch. 145 | 146 | ### Enhancements 147 | 148 | - Ease of key configuration. We provide optional built-in support with Elixir's `Mix.Config` system. See our configuration guide for more details; 149 | - Portable configuration using `Joken.Claim`; 150 | - Encapsulate your token logic in a module with `Joken.Config`; 151 | - Better error handling. We provide a lot more context in error messages; 152 | - A good performance analysis for ensuring this hot-path in APIs won't be your bottleneck. Please see our performance documentation to check what we are talking about; 153 | - Hooks for extending Joken functionality. All core actions in Joken have a corresponding hook for extending its functionality; 154 | - Guides for common patterns; 155 | 156 | ### Backwards incompatible changes 157 | 158 | - There is no `Joken.Plug` module anymore. Depending on requests we can bring that back, but we believe it is better to be on a different library; 159 | - The API surface changed a lot but you can still use Joken with the same [token pattern as versions 1.x](http://trivelop.de/2018/05/14/flow-elixir-designing-apis/). Please see our [migrating guide](https://github.com/joken-elixir/joken/blob/main/guides/migration_from_1.md). 160 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Joken 2 | 3 | [![CI](https://github.com/joken-elixir/joken/actions/workflows/ci.yml/badge.svg)](https://github.com/joken-elixir/joken/actions/workflows/ci.yml) 4 | [![Module Version](https://img.shields.io/hexpm/v/joken.svg)](https://hex.pm/packages/joken) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/joken/) 6 | [![Total Download](https://img.shields.io/hexpm/dt/joken.svg)](https://hex.pm/packages/joken) 7 | [![License](https://img.shields.io/hexpm/l/joken.svg)](https://github.com/joken-elixir/joken/blob/main/LICENSE) 8 | [![Last Updated](https://img.shields.io/github/last-commit/joken-elixir/joken.svg)](https://github.com/joken-elixir/joken/commits/main) 9 | 10 | A JSON Web Token (JWT) Library. 11 | 12 | Please, do read our comprehensive documentation and guides: 13 | 14 | - [Changelog](https://hexdocs.pm/joken/changelog.html) 15 | - [Joken Overview](https://hexdocs.pm/joken/introduction.html) 16 | - [Configuration](https://hexdocs.pm/joken/configuration.html) 17 | - [Signers](https://hexdocs.pm/joken/signers.html) 18 | - [Asymmetric cryptography signers](https://hexdocs.pm/joken/asymmetric_cryptography_signers.html) 19 | - [Testing your app with Joken](https://hexdocs.pm/joken/testing.html) 20 | - [JWT Common use cases](https://hexdocs.pm/joken/common_use_cases.html) 21 | - [Migrating from Joken 1.0](https://hexdocs.pm/joken/migration_from_1.html) 22 | - [Custom header arguments](https://hexdocs.pm/joken/custom_header_arguments.html) 23 | 24 | ## Usage 25 | 26 | Add `:joken` to your list of dependencies in `mix.exs`: 27 | 28 | ``` elixir 29 | def deps do 30 | # .. other deps 31 | {:joken, "~> 2.6"}, 32 | # Recommended JSON library 33 | {:jason, "~> 1.4"} 34 | end 35 | ``` 36 | 37 | All set! (don't forget to take a look at our comprehensive [documentation and guides](https://hexdocs.pm/joken/introduction.html)!) 38 | 39 | ## Benchmarking 40 | 41 | Just run the benchmark script based on the supported algorithm: 42 | 43 | ``` shell 44 | mix run benchmarks/hs_benchmark.exs 45 | mix run benchmarks/jose_hs_benchmark.exs 46 | mix run benchmarks/pem_rs_benchmark.exs 47 | mix run benchmarks/rs_benchmark.exs 48 | ``` 49 | 50 | ## License 51 | 52 | Copyright (c) 2014 Bryan Joseph 53 | 54 | Licensed under the Apache License, Version 2.0 (the "License"); 55 | you may not use this file except in compliance with the License. 56 | You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0) 57 | 58 | Unless required by applicable law or agreed to in writing, software 59 | distributed under the License is distributed on an "AS IS" BASIS, 60 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 61 | See the License for the specific language governing permissions and 62 | limitations under the License. 63 | 64 | ## Disclaimer 65 | 66 | This library would not be possible without the work of @potatosalad (Andrew Bennet). Specifically his library [erlang-jose](https://github.com/potatosalad/erlang-jose/). 67 | -------------------------------------------------------------------------------- /benchmarks/hs_benchmark.exs: -------------------------------------------------------------------------------- 1 | defmodule HS256Auth do 2 | use Joken.Config, default_signer: :hs256 3 | end 4 | 5 | defmodule HS384Auth do 6 | use Joken.Config, default_signer: :hs384 7 | end 8 | 9 | defmodule HS512Auth do 10 | use Joken.Config, default_signer: :hs512 11 | end 12 | 13 | defmodule HS256AuthVerify do 14 | use Joken.Config, default_signer: :hs256 15 | 16 | def token_config do 17 | %{} 18 | |> add_claim("name", fn -> "John Doe" end, &(&1 == "John Doe")) 19 | |> add_claim("test", fn -> true end, &(&1 == true)) 20 | |> add_claim("age", fn -> 666 end, &(&1 > 18)) 21 | |> add_claim("simple time test", fn -> 1 end, &(Joken.current_time() > &1)) 22 | end 23 | end 24 | 25 | defmodule HS384AuthVerify do 26 | use Joken.Config, default_signer: :hs384 27 | 28 | def token_config do 29 | %{} 30 | |> add_claim("name", fn -> "John Doe" end, &(&1 == "John Doe")) 31 | |> add_claim("test", fn -> true end, &(&1 == true)) 32 | |> add_claim("age", fn -> 666 end, &(&1 > 18)) 33 | |> add_claim("simple time test", fn -> 1 end, &(Joken.current_time() > &1)) 34 | end 35 | end 36 | 37 | defmodule HS512AuthVerify do 38 | use Joken.Config, default_signer: :hs512 39 | 40 | def token_config do 41 | %{} 42 | |> add_claim("name", fn -> "John Doe" end, &(&1 == "John Doe")) 43 | |> add_claim("test", fn -> true end, &(&1 == true)) 44 | |> add_claim("age", fn -> 666 end, &(&1 > 18)) 45 | |> add_claim("simple time test", fn -> 1 end, &(Joken.current_time() > &1)) 46 | end 47 | end 48 | 49 | hs256_token = 50 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZ2UiOjY2NiwibmFtZSI6IkpvaG4gRG9lIiwic2ltcGxlIHRpbWUgdGVzdCI6MSwidGVzdCI6dHJ1ZX0.AxM6-iOez0tM35N6hSxr_LWe9LC28c4MeoRvEIi4Gtw" 51 | 52 | hs384_token = 53 | "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJhZ2UiOjY2NiwibmFtZSI6IkpvaG4gRG9lIiwic2ltcGxlIHRpbWUgdGVzdCI6MSwidGVzdCI6dHJ1ZX0.35wYGZk5Dzka_BMzeplo9sz0q_BDwg_C2m-_xqp-6RBVU7qyhudAwy8hFY1Dxti_" 54 | 55 | hs512_token = 56 | "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhZ2UiOjY2NiwibmFtZSI6IkpvaG4gRG9lIiwic2ltcGxlIHRpbWUgdGVzdCI6MSwidGVzdCI6dHJ1ZX0.P7Og_ODvM94PPXettTtalgiGtxwj7oBoDk_4zj08o3kRZPQCDqNy4lHanoEhY-CTS-CPbJKivelnxMGBJ-3x5A" 57 | 58 | Benchee.run( 59 | %{ 60 | "HS256 generate and sign" => fn -> HS256Auth.generate_and_sign() end, 61 | "HS384 generate and sign" => fn -> HS384Auth.generate_and_sign() end, 62 | "HS512 generate and sign" => fn -> HS512Auth.generate_and_sign() end 63 | }, 64 | time: 5 65 | ) 66 | 67 | Benchee.run( 68 | %{ 69 | "HS256 verify and validate" => fn -> HS256AuthVerify.verify_and_validate(hs256_token) end, 70 | "HS384 verify and validate" => fn -> HS384AuthVerify.verify_and_validate(hs384_token) end, 71 | "HS512 verify and validate" => fn -> HS512AuthVerify.verify_and_validate(hs512_token) end 72 | }, 73 | time: 5 74 | ) 75 | -------------------------------------------------------------------------------- /benchmarks/jose_hs_benchmark.exs: -------------------------------------------------------------------------------- 1 | jwk_hs256 = JOSE.JWK.generate_key({:oct, 16}) 2 | jwk_hs384 = JOSE.JWK.generate_key({:oct, 24}) 3 | jwk_hs512 = JOSE.JWK.generate_key({:oct, 32}) 4 | 5 | jws_hs256 = JOSE.JWS.from_map(%{"alg" => "HS256", "typ" => "JWT"}) 6 | jws_hs384 = JOSE.JWS.from_map(%{"alg" => "HS384", "typ" => "JWT"}) 7 | jws_hs512 = JOSE.JWS.from_map(%{"alg" => "HS512", "typ" => "JWT"}) 8 | 9 | Benchee.run(%{ 10 | "JOSE HS256" => fn -> 11 | # Same as default claims for Joken 12 | jwt = %{ 13 | "exp" => Joken.CurrentTime.OS.current_time() + 2 * 60 * 60, 14 | "iss" => "Joken", 15 | "nbf" => Joken.CurrentTime.OS.current_time(), 16 | "iat" => Joken.CurrentTime.OS.current_time() 17 | } 18 | 19 | JOSE.JWT.sign(jwk_hs256, jws_hs256, jwt) |> JOSE.JWS.compact() 20 | end, 21 | "JOSE HS384" => fn -> 22 | # Same as default claims for Joken 23 | jwt = %{ 24 | "exp" => Joken.CurrentTime.OS.current_time() + 2 * 60 * 60, 25 | "iss" => "Joken", 26 | "nbf" => Joken.CurrentTime.OS.current_time(), 27 | "iat" => Joken.CurrentTime.OS.current_time() 28 | } 29 | 30 | JOSE.JWT.sign(jwk_hs384, jws_hs384, jwt) |> JOSE.JWS.compact() 31 | end, 32 | "JOSE HS512" => fn -> 33 | # Same as default claims for Joken 34 | jwt = %{ 35 | "exp" => Joken.CurrentTime.OS.current_time() + 2 * 60 * 60, 36 | "iss" => "Joken", 37 | "nbf" => Joken.CurrentTime.OS.current_time(), 38 | "iat" => Joken.CurrentTime.OS.current_time() 39 | } 40 | 41 | JOSE.JWT.sign(jwk_hs512, jws_hs512, jwt) |> JOSE.JWS.compact() 42 | end 43 | }) 44 | -------------------------------------------------------------------------------- /benchmarks/pem_rs_benchmark.exs: -------------------------------------------------------------------------------- 1 | defmodule RS256Auth do 2 | use Joken.Config, default_signer: :pem_rs256 3 | end 4 | 5 | defmodule RS384Auth do 6 | use Joken.Config, default_signer: :pem_rs384 7 | end 8 | 9 | defmodule RS512Auth do 10 | use Joken.Config, default_signer: :pem_rs512 11 | end 12 | 13 | defmodule RS256AuthVerify do 14 | use Joken.Config, default_signer: :rs256 15 | 16 | def token_config do 17 | %{} 18 | |> add_claim("name", fn -> "John Doe" end, &(&1 == "John Doe")) 19 | |> add_claim("test", fn -> true end, &(&1 == true)) 20 | |> add_claim("age", fn -> 666 end, &(&1 > 18)) 21 | |> add_claim("simple time test", fn -> 1 end, &(Joken.current_time() > &1)) 22 | end 23 | end 24 | 25 | defmodule RS384AuthVerify do 26 | use Joken.Config, default_signer: :rs384 27 | 28 | def token_config do 29 | %{} 30 | |> add_claim("name", fn -> "John Doe" end, &(&1 == "John Doe")) 31 | |> add_claim("test", fn -> true end, &(&1 == true)) 32 | |> add_claim("age", fn -> 666 end, &(&1 > 18)) 33 | |> add_claim("simple time test", fn -> 1 end, &(Joken.current_time() > &1)) 34 | end 35 | end 36 | 37 | defmodule RS512AuthVerify do 38 | use Joken.Config, default_signer: :rs512 39 | 40 | def token_config do 41 | %{} 42 | |> add_claim("name", fn -> "John Doe" end, &(&1 == "John Doe")) 43 | |> add_claim("test", fn -> true end, &(&1 == true)) 44 | |> add_claim("age", fn -> 666 end, &(&1 > 18)) 45 | |> add_claim("simple time test", fn -> 1 end, &(Joken.current_time() > &1)) 46 | end 47 | end 48 | 49 | rs256_token = 50 | "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZ2UiOjY2NiwibmFtZSI6IkpvaG4gRG9lIiwic2ltcGxlIHRpbWUgdGVzdCI6MSwidGVzdCI6dHJ1ZX0.koLkuRCtAvWrEsrRDtWeiwJiRsEhaZxWexxrM-qiEh-1U-6fnLw9AY3qoO5Pb7UiUO1GviAateM8QkvKtv1VnIfzr0kZWx9rHJmVeTXhp9RtA24l1gn2vfi-6Q5eXmvk--AMhUNF3JdG35eKGbLNc3RMMyH0gj8Q4KNPeQKVnKErKRa8mLIBWdi-LXrCfhVbRJdhMQNI_jJ3I04EJ0ZsqjNXHGOepaNQ_MDYc306nXsfbMyIa90jnnNhYtLMofWCY8S3eqOW-SKoRZw5ztBIJIomLnYEon7sipolGOcJA_t7E4vmqaOP3pdg_n_Afsa0OGfXEZ5fIfBqTSQ_ivlr4Q" 51 | 52 | rs384_token = 53 | "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJhZ2UiOjY2NiwibmFtZSI6IkpvaG4gRG9lIiwic2ltcGxlIHRpbWUgdGVzdCI6MSwidGVzdCI6dHJ1ZX0.M3RObqG5KnbuxJvqOrvve63zEdQFqBIERBUzKc0Ibz0VmVDjJfEI7MiNxktV7KwNNYzL3SVAVQN44I6uIiltMIQgeJgo5WKBdV4j1IUIqYf43iiqBt4CjCuMSOAFsGQjftHA5VdfMmoIFAkT29ImPokN7oJsMnmuPK3HIaKcMjcuG0z0m938nA85nwi1-wPbPUYPOJmt77Dwllp7mrBoZj_siLkURcHLUL45IlA-22MuqkJccMhW8FtHurGPOub0d9EH3TO0Ge5E3o78k33rPHpJVAWxHYw7-Yypsf1CrObuSB1MDXA69USLateG9mcRJVoxKCpLzSFI6V5gvO58MQ" 54 | 55 | rs512_token = 56 | "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhZ2UiOjY2NiwibmFtZSI6IkpvaG4gRG9lIiwic2ltcGxlIHRpbWUgdGVzdCI6MSwidGVzdCI6dHJ1ZX0.iGIyevqNEl_Mb6Ijm5PcA5_-xBk_NMzKAq_O6ZuNfKS9IVLO5pt46FiQ27oYBzCXUBORSHVJhkkhODLRoiYYlzMerL14WG8yUpVh1W3PjPJhxdg2YhBGySHnLSUMvyMM0mLKoslQd5arOOJCvmOkocpFOk-3U2rmjYOLDngqBAiXtnI12xP-uWrLIsWd3_B9TKSBmXGlBrkEUsDv2yBNlpmi4q-W1BK6VPS2_VHc2I4dZS1FDrWsrGb6RUijhBwo91sBcW2LKxm0Y_TIKnhT8OVWt_dfI2Lk_KtRVr9Ra0i4XN-H1zEX1Dg7ViVnh1NNsyIzi2WKmCMz6m1P2ocTjA" 57 | 58 | Benchee.run( 59 | %{ 60 | "RS256 generate and sign" => fn -> RS256Auth.generate_and_sign() end, 61 | "RS384 generate and sign" => fn -> RS384Auth.generate_and_sign() end, 62 | "RS512 generate and sign" => fn -> RS512Auth.generate_and_sign() end 63 | }, 64 | time: 5 65 | ) 66 | 67 | Benchee.run( 68 | %{ 69 | "RS256 verify and validate" => fn -> RS256AuthVerify.verify_and_validate(rs256_token) end, 70 | "RS384 verify and validate" => fn -> RS384AuthVerify.verify_and_validate(rs384_token) end, 71 | "RS512 verify and validate" => fn -> RS512AuthVerify.verify_and_validate(rs512_token) end 72 | }, 73 | time: 5 74 | ) 75 | -------------------------------------------------------------------------------- /benchmarks/rs_benchmark.exs: -------------------------------------------------------------------------------- 1 | defmodule RS256Auth do 2 | use Joken.Config, default_signer: :rs256 3 | end 4 | 5 | defmodule RS384Auth do 6 | use Joken.Config, default_signer: :rs384 7 | end 8 | 9 | defmodule RS512Auth do 10 | use Joken.Config, default_signer: :rs512 11 | end 12 | 13 | defmodule RS256AuthVerify do 14 | use Joken.Config, default_signer: :rs256 15 | 16 | def token_config do 17 | %{} 18 | |> add_claim("name", fn -> "John Doe" end, &(&1 == "John Doe")) 19 | |> add_claim("test", fn -> true end, &(&1 == true)) 20 | |> add_claim("age", fn -> 666 end, &(&1 > 18)) 21 | |> add_claim("simple time test", fn -> 1 end, &(Joken.current_time() > &1)) 22 | end 23 | end 24 | 25 | defmodule RS384AuthVerify do 26 | use Joken.Config, default_signer: :rs384 27 | 28 | def token_config do 29 | %{} 30 | |> add_claim("name", fn -> "John Doe" end, &(&1 == "John Doe")) 31 | |> add_claim("test", fn -> true end, &(&1 == true)) 32 | |> add_claim("age", fn -> 666 end, &(&1 > 18)) 33 | |> add_claim("simple time test", fn -> 1 end, &(Joken.current_time() > &1)) 34 | end 35 | end 36 | 37 | defmodule RS512AuthVerify do 38 | use Joken.Config, default_signer: :rs512 39 | 40 | def token_config do 41 | %{} 42 | |> add_claim("name", fn -> "John Doe" end, &(&1 == "John Doe")) 43 | |> add_claim("test", fn -> true end, &(&1 == true)) 44 | |> add_claim("age", fn -> 666 end, &(&1 > 18)) 45 | |> add_claim("simple time test", fn -> 1 end, &(Joken.current_time() > &1)) 46 | end 47 | end 48 | 49 | rs256_token = 50 | "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZ2UiOjY2NiwibmFtZSI6IkpvaG4gRG9lIiwic2ltcGxlIHRpbWUgdGVzdCI6MSwidGVzdCI6dHJ1ZX0.koLkuRCtAvWrEsrRDtWeiwJiRsEhaZxWexxrM-qiEh-1U-6fnLw9AY3qoO5Pb7UiUO1GviAateM8QkvKtv1VnIfzr0kZWx9rHJmVeTXhp9RtA24l1gn2vfi-6Q5eXmvk--AMhUNF3JdG35eKGbLNc3RMMyH0gj8Q4KNPeQKVnKErKRa8mLIBWdi-LXrCfhVbRJdhMQNI_jJ3I04EJ0ZsqjNXHGOepaNQ_MDYc306nXsfbMyIa90jnnNhYtLMofWCY8S3eqOW-SKoRZw5ztBIJIomLnYEon7sipolGOcJA_t7E4vmqaOP3pdg_n_Afsa0OGfXEZ5fIfBqTSQ_ivlr4Q" 51 | 52 | rs384_token = 53 | "eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJhZ2UiOjY2NiwibmFtZSI6IkpvaG4gRG9lIiwic2ltcGxlIHRpbWUgdGVzdCI6MSwidGVzdCI6dHJ1ZX0.M3RObqG5KnbuxJvqOrvve63zEdQFqBIERBUzKc0Ibz0VmVDjJfEI7MiNxktV7KwNNYzL3SVAVQN44I6uIiltMIQgeJgo5WKBdV4j1IUIqYf43iiqBt4CjCuMSOAFsGQjftHA5VdfMmoIFAkT29ImPokN7oJsMnmuPK3HIaKcMjcuG0z0m938nA85nwi1-wPbPUYPOJmt77Dwllp7mrBoZj_siLkURcHLUL45IlA-22MuqkJccMhW8FtHurGPOub0d9EH3TO0Ge5E3o78k33rPHpJVAWxHYw7-Yypsf1CrObuSB1MDXA69USLateG9mcRJVoxKCpLzSFI6V5gvO58MQ" 54 | 55 | rs512_token = 56 | "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhZ2UiOjY2NiwibmFtZSI6IkpvaG4gRG9lIiwic2ltcGxlIHRpbWUgdGVzdCI6MSwidGVzdCI6dHJ1ZX0.iGIyevqNEl_Mb6Ijm5PcA5_-xBk_NMzKAq_O6ZuNfKS9IVLO5pt46FiQ27oYBzCXUBORSHVJhkkhODLRoiYYlzMerL14WG8yUpVh1W3PjPJhxdg2YhBGySHnLSUMvyMM0mLKoslQd5arOOJCvmOkocpFOk-3U2rmjYOLDngqBAiXtnI12xP-uWrLIsWd3_B9TKSBmXGlBrkEUsDv2yBNlpmi4q-W1BK6VPS2_VHc2I4dZS1FDrWsrGb6RUijhBwo91sBcW2LKxm0Y_TIKnhT8OVWt_dfI2Lk_KtRVr9Ra0i4XN-H1zEX1Dg7ViVnh1NNsyIzi2WKmCMz6m1P2ocTjA" 57 | 58 | Benchee.run( 59 | %{ 60 | "RS256 generate and sign" => fn -> RS256Auth.generate_and_sign() end, 61 | "RS384 generate and sign" => fn -> RS384Auth.generate_and_sign() end, 62 | "RS512 generate and sign" => fn -> RS512Auth.generate_and_sign() end 63 | }, 64 | time: 5 65 | ) 66 | 67 | Benchee.run( 68 | %{ 69 | "RS256 verify and validate" => fn -> RS256AuthVerify.verify_and_validate(rs256_token) end, 70 | "RS384 verify and validate" => fn -> RS384AuthVerify.verify_and_validate(rs384_token) end, 71 | "RS512 verify and validate" => fn -> RS512AuthVerify.verify_and_validate(rs512_token) end 72 | }, 73 | time: 5 74 | ) 75 | -------------------------------------------------------------------------------- /config/.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "src/"], 25 | excluded: [~r"/_build/", ~r"/deps/"] 26 | }, 27 | # 28 | # If you create your own checks, you must specify the source files for 29 | # them here, so they can be loaded by Credo before running the analysis. 30 | # 31 | requires: [], 32 | # 33 | # If you want to enforce a style guide and need a more traditional linting 34 | # experience, you can change `strict` to `true` below: 35 | # 36 | strict: false, 37 | # 38 | # If you want to use uncolored output by default, you can change `color` 39 | # to `false` below: 40 | # 41 | color: true, 42 | # 43 | # You can customize the parameters of any check by adding a second element 44 | # to the tuple. 45 | # 46 | # To disable a check put `false` as second element: 47 | # 48 | # {Credo.Check.Design.DuplicatedCode, false} 49 | # 50 | checks: [ 51 | # 52 | ## Consistency Checks 53 | # 54 | {Credo.Check.Consistency.ExceptionNames}, 55 | {Credo.Check.Consistency.LineEndings}, 56 | {Credo.Check.Consistency.ParameterPatternMatching}, 57 | {Credo.Check.Consistency.SpaceAroundOperators}, 58 | {Credo.Check.Consistency.SpaceInParentheses}, 59 | {Credo.Check.Consistency.TabsOrSpaces}, 60 | 61 | # 62 | ## Design Checks 63 | # 64 | # You can customize the priority of any check 65 | # Priority values are: `low, normal, high, higher` 66 | # 67 | {Credo.Check.Design.AliasUsage, priority: :low}, 68 | # For some checks, you can also set other parameters 69 | # 70 | # If you don't want the `setup` and `test` macro calls in ExUnit tests 71 | # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just 72 | # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. 73 | # 74 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 75 | # You can also customize the exit_status of each check. 76 | # If you don't want TODO comments to cause `mix credo` to fail, just 77 | # set this value to 0 (zero). 78 | # 79 | {Credo.Check.Design.TagTODO, exit_status: 2}, 80 | {Credo.Check.Design.TagFIXME}, 81 | 82 | # 83 | ## Readability Checks 84 | # 85 | {Credo.Check.Readability.AliasOrder}, 86 | {Credo.Check.Readability.FunctionNames}, 87 | {Credo.Check.Readability.LargeNumbers}, 88 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 120}, 89 | {Credo.Check.Readability.ModuleAttributeNames}, 90 | {Credo.Check.Readability.ModuleDoc}, 91 | {Credo.Check.Readability.ModuleNames}, 92 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, 93 | {Credo.Check.Readability.ParenthesesInCondition}, 94 | {Credo.Check.Readability.PredicateFunctionNames}, 95 | {Credo.Check.Readability.PreferImplicitTry}, 96 | {Credo.Check.Readability.RedundantBlankLines}, 97 | {Credo.Check.Readability.StringSigils}, 98 | {Credo.Check.Readability.TrailingBlankLine}, 99 | {Credo.Check.Readability.TrailingWhiteSpace}, 100 | {Credo.Check.Readability.VariableNames}, 101 | {Credo.Check.Readability.Semicolons}, 102 | {Credo.Check.Readability.SpaceAfterCommas}, 103 | 104 | # 105 | ## Refactoring Opportunities 106 | # 107 | {Credo.Check.Refactor.DoubleBooleanNegation}, 108 | {Credo.Check.Refactor.CondStatements}, 109 | {Credo.Check.Refactor.CyclomaticComplexity}, 110 | {Credo.Check.Refactor.FunctionArity}, 111 | {Credo.Check.Refactor.LongQuoteBlocks}, 112 | {Credo.Check.Refactor.MapInto}, 113 | {Credo.Check.Refactor.MatchInCondition}, 114 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 115 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 116 | {Credo.Check.Refactor.Nesting}, 117 | {Credo.Check.Refactor.PipeChainStart, 118 | excluded_argument_types: [:atom, :binary, :fn, :keyword], excluded_functions: []}, 119 | {Credo.Check.Refactor.UnlessWithElse}, 120 | 121 | # 122 | ## Warnings 123 | # 124 | {Credo.Check.Warning.BoolOperationOnSameValues}, 125 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck}, 126 | {Credo.Check.Warning.IExPry}, 127 | {Credo.Check.Warning.IoInspect}, 128 | {Credo.Check.Warning.LazyLogging}, 129 | {Credo.Check.Warning.OperationOnSameValues}, 130 | {Credo.Check.Warning.OperationWithConstantResult}, 131 | {Credo.Check.Warning.UnusedEnumOperation}, 132 | {Credo.Check.Warning.UnusedFileOperation}, 133 | {Credo.Check.Warning.UnusedKeywordOperation}, 134 | {Credo.Check.Warning.UnusedListOperation}, 135 | {Credo.Check.Warning.UnusedPathOperation}, 136 | {Credo.Check.Warning.UnusedRegexOperation}, 137 | {Credo.Check.Warning.UnusedStringOperation}, 138 | {Credo.Check.Warning.UnusedTupleOperation}, 139 | {Credo.Check.Warning.RaiseInsideRescue}, 140 | 141 | # 142 | # Controversial and experimental checks (opt-in, just remove `, false`) 143 | # 144 | {Credo.Check.Refactor.ABCSize, false}, 145 | {Credo.Check.Refactor.AppendSingleItem, false}, 146 | {Credo.Check.Refactor.VariableRebinding, false}, 147 | {Credo.Check.Warning.MapGetUnsafePass, false}, 148 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 149 | 150 | # 151 | # Deprecated checks (these will be deleted after a grace period) 152 | # 153 | {Credo.Check.Readability.Specs, false} 154 | 155 | # 156 | # Custom checks can be created using `mix credo.gen.check`. 157 | # 158 | ] 159 | } 160 | ] 161 | } 162 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | case config_env() do 4 | # Use the same key config as tests for benchmarking 5 | :bench -> 6 | import_config("test.exs") 7 | 8 | # Override mock adapter 9 | config :joken, current_time_adapter: Joken.CurrentTime.OS 10 | 11 | :docs -> 12 | :ok 13 | 14 | _ -> 15 | import_config("#{Mix.env()}.exs") 16 | end 17 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :joken, default_signer: "s3cr3t" 4 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | rsa_map_key = %{ 6 | "d" => 7 | "A2gHIUmJOzRGvklIA2S8wWayCXnF8NYAhOhu7woSwjioO3HRzvd3ptegSKDpPfABJuzhy7y08ug5ZcyFbN1hJBVY8NwNzpLSUK9wmXekrbTG9MT76NAiQTxV6fYK5DXPF4Cp0qghBt-tq0kQNKx4q9QEzLb9XonmXE2a10U8EWJIs972SFGhxKzf6aq6Ri7UDK607ngQyEhVmGxr3gDJLAGQ5wOap5NYIL2ufI5FYqH-Sby_Qk7299b-w4B0fl6u8isR8OlpwMLVnD-oqOBPH-65tE82hxPV0QbSmyzmg9hlVVinJ82YRBkbcu-XG9XXOhUqJJ7kafQrYkQx6BiFKQ", 8 | "dp" => 9 | "Useg361ca8Aem1TToW8AfjOLAAEqkkR48UPMSS2Le9D4YFtAb_ud5CK2IevYl0R-4afXUzIoeiNRg4bOTAWmTwKKlmAp4B5GzlbPzAPhwQRCxzs5MiW0K-Nw30blBLWlJYDAnVEr3T3rqtgzXFLMhR5AHqM4VhWQK7QaxgaW7TE", 10 | "dq" => 11 | "yueW-DmyJULJlJckFXfkivSO_X1sjQurDwDfyFLAnrvgy2EqJ-iq0gBVySMGw2CgeSQegTmuKinF4anL0wy85BK8tgxDULVOpjls4ej8ZQnJ2RVEjdxZLjKh-2yw-v6mbn7goko98nkRCBYMdDUBHNVcaY9bA8kdBWi-K6DgW2E", 12 | "e" => "AQAB", 13 | "kty" => "RSA", 14 | "n" => 15 | "xnAUUvtW3ftv25jCB-hePVCnhROqH2PACVGoCybdtMYTl8qVABAR0d6T-BRzVhJzz0-UvBNFUQyVvKAFxtbQUZN2JgAm08UJrDQszqz5tTzodWexODdPuoCaWaWge_MZGhz5PwWd7Jc4bPAu0QzSVFpBP3CovSjv48Z2Eq0_LHXVjjX_Az-WaUh94mXFyAxFI_oCygtT-il1-japS3cXJJh0WddT3VKEBRYHmxDJd_LYE-KXQt3aTDhq0vI9sG2ivtFj0dc3w_YBdr4hlcr42ujSP3wLTPpTjituwHQhYP4j-zqu7J3FYaIxU4lkK9Y_DP27RxffFI9YDPJdwFkNJw", 16 | "p" => 17 | "5cMQg_4MrOnHI44xEs6Jyt_22DCvw3K-GY046Ls50vIf2KlRALHI65SPKfVFo5hUuHkBuWnQV46tHJU0dlmfg4svPMm_581r59yXeI8W6G4FlsSiVyhFO3P5Q5ubVs7MNaqhvaqqPqR14cVvHSqjwX5jGuGAVuLhnOhZGbtb7_U", 18 | "q" => 19 | "3RlGNrCRU-yV7TTikKJVJCIpe8vgLBkHQ61iuICd8AyHa4sXICgf2YBFgW8CAJOHKIp8g_Nl94VYpqWvN1YVDB7sFUlRpJL2yXvTKxDzUwtM5pf_D1O6lGEMQBRY-buhZHmPf5qG93LnsSqm5YOZGpZ6t6gHtYM9A6JOIgwsYys", 20 | "qi" => 21 | "kG5Stetls18_1fvQx8rxhX2Ais0Xg0gLDUjpE_9TYcb-utq79HVKOQ_2PJGz09hQ_teqnhXhgGMubqaktl6UOSJr6B4JgcAY7yU-34EuSxp8uKLix9BVsF2cpiC4ADhjLKP9c7IQ7X7zfs336_Reb8fh9G_zRdwEfmqFy7m28Lg" 22 | } 23 | 24 | rsa_public_pem_key = """ 25 | -----BEGIN PUBLIC KEY----- 26 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA42Ly+LsapY7raIdo1buG 27 | g0LX5SEArOjLHjkr8iOm+PO/GXEq2SrAr+XTgVT3MRMmif16RFPrBWXvtwwaUkwo 28 | 3S16URGxX/YdiHfT8If21hn9wkpiOSvo7Z62wpquHSBoZyu9NxNM/wqvxD17GuYQ 29 | Bsyb6stGI9bir+GjORKLT1b5ayIfUeUK5qaWhjXQoL/yI1ZeRL0DVR+72CKW7/v+ 30 | LlHBvS1H1wpKy/dcMXxVzXqWutsfg+fXT7SlwWyFH6MUr9RTTYf8Kc0Pv3siSxmb 31 | R897Sv4o9A2c82qet+2snuDuDKur3fmsmfY9UqKxh4qPAWPuxFVegdE5Tgy8CJVJ 32 | idffeTS6Fp71DNUhi9bzYoQi/mx3wm9WUD9GHABSFiEa43V2mZwHcWtdS5CljWf3 33 | n0IO3Jxoww1ZOwn788PnJrKe8LESKH0WujEWXvLBJnu+DUF7fG94/gsrQXS6EEBg 34 | 1DpQMSnozsQyQVHqvuZ4KsZkplP5I14pgf5uw/6BAcSlyCRbv6rIfo4BKr9bk1Pv 35 | qHiog3uFg6yyPUNwwTUQHDstOYVD1L06D10QpiznRG/h2doKtUGRHXvUv9z2OeAO 36 | FXTkS+g6cgX+KSvnwwwjbHofE4/pOYe5r5owiWkQ0hTT+FcrDkN/PLaRa1Nr5Ezz 37 | zli2XeFVQeAzLQueutCqSAECAwEAAQ== 38 | -----END PUBLIC KEY----- 39 | """ 40 | 41 | rsa_pem_key = """ 42 | -----BEGIN RSA PRIVATE KEY----- 43 | MIIJKAIBAAKCAgEA42Ly+LsapY7raIdo1buGg0LX5SEArOjLHjkr8iOm+PO/GXEq 44 | 2SrAr+XTgVT3MRMmif16RFPrBWXvtwwaUkwo3S16URGxX/YdiHfT8If21hn9wkpi 45 | OSvo7Z62wpquHSBoZyu9NxNM/wqvxD17GuYQBsyb6stGI9bir+GjORKLT1b5ayIf 46 | UeUK5qaWhjXQoL/yI1ZeRL0DVR+72CKW7/v+LlHBvS1H1wpKy/dcMXxVzXqWutsf 47 | g+fXT7SlwWyFH6MUr9RTTYf8Kc0Pv3siSxmbR897Sv4o9A2c82qet+2snuDuDKur 48 | 3fmsmfY9UqKxh4qPAWPuxFVegdE5Tgy8CJVJidffeTS6Fp71DNUhi9bzYoQi/mx3 49 | wm9WUD9GHABSFiEa43V2mZwHcWtdS5CljWf3n0IO3Jxoww1ZOwn788PnJrKe8LES 50 | KH0WujEWXvLBJnu+DUF7fG94/gsrQXS6EEBg1DpQMSnozsQyQVHqvuZ4KsZkplP5 51 | I14pgf5uw/6BAcSlyCRbv6rIfo4BKr9bk1PvqHiog3uFg6yyPUNwwTUQHDstOYVD 52 | 1L06D10QpiznRG/h2doKtUGRHXvUv9z2OeAOFXTkS+g6cgX+KSvnwwwjbHofE4/p 53 | OYe5r5owiWkQ0hTT+FcrDkN/PLaRa1Nr5Ezzzli2XeFVQeAzLQueutCqSAECAwEA 54 | AQKCAgBT1CnhOxFy0cLF0Y37pdvMTntLdKRMGrKvXMJvzWcERtA/7/GtCE7rSh50 55 | gr9y7y15F+LYh9uQLOl4IVUe3AcAq4B5nL04tIJkylBvT6DXg9OCqmuVyjNgTu/v 56 | xJjGEimLR5vFTy9Go0jzXvsgioqEBzDAGdqs4c7GqrfDFawYPudK8NR9G6SuLeI2 57 | bmaQrNL25iNw0gIFguJ8pxvgM5Wcu/Vh6eyfQaMbmQD7GWyEiVpCXwA6X+GH+ABX 58 | 08ssQ7IftHZVkfmL65aPsDSPXUxa6An7NsIgX1hqgPcstcm69Q+tyihdeGuCgz5O 59 | Vb5/Sry39YCUDyj9UQYAWT+FJoxqO/gmBYu+fymzvzqE6eYJW7esJgqjUXawYppM 60 | sTCB0yYEDryBvK2Iqft7cUhNwKvO5MdOf9oWWIeIgjis8leUiIVYf7MGiedwfMny 61 | DW6G8GlEvllq2CYr0AP8PTqriv6lf21YDVye3HuGChJUcPpFd1jLys/duGGqHSWj 62 | rrGsygF9Z6Jnr5MNHIB5iuwY1fSGAlNOvxjwLYdCAVeutvqnz8Bro/Wxk4HSnbM6 63 | BlS38SL6uSBN4MBFkyOvxBbu/eFd5XDRpL7T+zFE9JhQletuWqBItOzivUo8UtrN 64 | tNyIOPlyU4+/8PVjGUm38yaak60mXhMAL6YD21iJ6eV1WQR1wQKCAQEA/WtOQpw7 65 | 45Z1DAz/myPbe5KYszFWVumoaEA2HruPYX7eaJspC2hmcrRdltA+5m37C9TJTrSt 66 | 9llbPo2K9GdDfIyGuMOYeB5Gg8oKRqvx+BjdHnUehvPUe1GlW0r1m3DWWkc2cw7M 67 | 9fLnF7IOhsZjljuhNrEk/BRdV/n9ooS7D9AWWlT34frzeLvb3y7C/l2PgT9rhUjY 68 | 9uIfQrwF2s6XowUSpKiZj+7PcWHKkTu/jCI+01Man9qiFvgwVVn6ARFgvuTUSSDA 69 | QXJbC/L9PA7S1j2eRgwTqcf/TAOKCSCmbFqmN6KGSyJSYTZA8GQXQJvZq7NuclfY 70 | NOK1rbyCgsxISQKCAQEA5bPF7kBwHr5QfQ3ZkP/s5uw4W0CJssqecEU2zhx3DKmJ 71 | mokQiQAKtikRcRxGidP8Fd2lq1dOKWfDdvNh3BIpNGC/NgKRiQt0yX0KVF3NWzMf 72 | +NTfbu+FceNKmz3Tj+HMipKfHleYHRMW0pUDc1d6T4KCQ1Vm7VuGP4iBcSw/dl7s 73 | l+aZJJBASydX42kYPEEH85e+xkYrDLPn9XpcMnnmN8F3Qxa8/fAjDX7HKc1LgWaq 74 | 7dlcjTK7puFBaSwjAAKBCc0rS6T/W+l6rOgI7vH7Qbdmlxlzci04A0DGCPTaV5nu 75 | Sp06xhgQi2f5HmWgtptiuB9Rb5TD8JWGi6yCIRIx+QKCAQBfpEXvAcPgiwI1wBof 76 | 1RKauqMCzhYFyz2RytoiEytz4kvSMuz0rzwrAkNoDcQPd2aN+orXN32IQgUbwJO2 77 | 1do0gVy/EqLSgqqeRnxGW9KAjfG18wHIcPG6cP/1Sn8TYSyk+cdk+SsFj90DpmGx 78 | H+Kp6mtXltecg5sO/vxof6uRtjkZcoPzN6D36f57ZsyU736fiu3raajo1EJ0Dz4u 79 | bFXyYpG8rxz1o22LHxsyYNhT7QDFBNJBjmQqQxUKwWCHUqWupfIwfznP+Xa/Nb+5 80 | EOclkC/Rw/EP3LlPWO6Zr0bgEf41dRM1/AgXRECR+VSFP8yQ7rE6Wkjw/LcQkNq4 81 | vpxxAoIBAB5uBGidrLzF5Y/Lh+kHnnCxFn4wPI2s/fRNlwcTCkppI0uPoNslYEXl 82 | huP/JPEZXinfZRUfycD+eAyIDYzD8yV3M52KFZGcLOqMYBPxIUVVroSeXsMpg/ok 83 | bDvIowBKn3g0GFRCsmoXn0xiZUSgcBmcZnlZFPuYxl4gTVWa0QVzadBtwhfv7DSI 84 | j8IWqBlDXDRPA/zsSsOyCaahgRlGwNLeFFiU6JCTgXFGPEgzZC9OVJKR2wrxj555 85 | 9Npj+HcF3eZYgcXRo+qfMZs6WgSdlfWMwFCAFKUpjGQR7qo9FbfJMqI71g9sHLT7 86 | HyuBtxq51wghTf6ELLjwdhSG0+5hpLECggEBAKYv00+xYnUNk4SQZvMM9AWGFI1T 87 | pQBGdbsGWTEiR+bGcuJnal5jXxHBgTklzwF5EIf/tvmSYPFzIrhUVPF8yExdJdFY 88 | u2xp/FpLfopfzPjuVSR5hJJDFF7Ytglk1gdkXmkwDW9MJt4Tn4P9N9rRJAg2Gtmo 89 | ZXHvpjGtL6OVum/Tski+yJUTOPwl8/k3h0xzCI5HodcAUSuO59Jhd6MLzTNHZoyx 90 | eLG6hKpGZtX1SGteGnGAqqagt1Nz4mOC5ZrDObVihAz1ejq1xEwd2is0igdBZb7A 91 | CSAnW8Md2j56RkvCnSPGab8eh5BjoGEInmSZWpUXvLJt91pZqX1jSbs1ZNg= 92 | -----END RSA PRIVATE KEY----- 93 | """ 94 | 95 | ec_pem_key = """ 96 | -----BEGIN EC PRIVATE KEY----- 97 | MHcCAQEEIBEdk34npuxz13CvGk/BS39dZ+2XAR6k9S4uNhtEMd3AoAoGCCqGSM49 98 | AwEHoUQDQgAExCkF/6mwf3HoEv4m+1+Pi702herRxJeycLHXiRpA8Nkj8tVjU9C6 99 | 5CRx1TdE4q4I8ympW4HrBm2qpPi3z6mEGw== 100 | -----END EC PRIVATE KEY----- 101 | """ 102 | 103 | # generated with openssl genrsa -aes256 4096 104 | rsa_encrypted_passphrase = "passphrase" 105 | 106 | rsa_encrypted_pem_key = """ 107 | -----BEGIN RSA PRIVATE KEY----- 108 | Proc-Type: 4,ENCRYPTED 109 | DEK-Info: AES-256-CBC,B07A62A211BBA625EA114A6121DF1230 110 | 111 | J5yL2SRLC86sqTuAfVthU7+PfgRZuWF7pN+5SFdis+fbM8Rb2+uwjtvvbQvIbQb9 112 | rRYBK6oWlyFYQkIdT10L+V+wyuYy2pLKb3CWEVAGpDwImmRrI2GlE9ieQ9d/noDw 113 | 3zsPYpgJRKDSHll6mQ32q4/tB98yWkbNvhmUyzfArfax79+4HqvzzXqP6k9fkPHr 114 | EylYWnZEVf354GEmoFWFZgXV0sV1LE5Xj0o+J8kW1sIv5u962SCrHHTgxIn/G6xz 115 | V/Lw85gw6/hzsAT8+eDExfRZ8BPo34e6KN/cKIzSRV7Yq7nBKnV+TACXT5/nszkT 116 | MxXcIXlD2phJwqeHPJewOVvXZH8KpWpN0xJ+cdl3FNwfNNCMO/WyfurGH4ygjJrB 117 | Jpw/OPiYE4KacYoeZwGDZSjanY9D+ohJpzUmQTSz7/G34G5T8WWdV30jSpq2/30P 118 | 1G8HJRjxacSLOGEhTF93aTGOlrffEZYDq5klixmkTjZdrbS0aiLV+CQlEtQRBHGj 119 | cgkn3qnMSB/HcUJFd1Lw57HX+k9EsTIBU8QjHWGVduPcei5Vv0Pw4Y8LrrOqPz30 120 | hAAnwm9CfETNXWuagvdW+77lCYzpHHzxgF0Fp+RBB8M2CAmc7q3N2pEMyOvegRAd 121 | z2hCsb/SPAQUM9SxZ3Y4X/O0YYhrhb8HEh/++SQQrIcYq+cO/GLNwf0d5lhr2hp1 122 | rK4Aq3Ms9AyMp7K7JEaPINEKJjSWNYnqxOraRmVRg4Kt/N788IhUyCON7NURJUZj 123 | ZQFTIIy8f+eDbOT+MXfDguLlbPdFJj12RFHGynWkMN3ftg1ooAul5ru11JTPeKhk 124 | bOtxix+faGzi/CVsZFOwqNtUMCJsajEqsA27kulG7n7/1LNV7AeDF/ZBXPRPPFNN 125 | 33LPWqobX7rceibizmE1bMpJaa82H/UXqH+lMrc/fMyg0sJ0U4W1JOZBh3kNrBRg 126 | 0chE/h3HRuCBQeWLCz6m+ppFuYOkgQ9UJyqJfwyJXuwMMncIaFO0IdjYPx3rW1ds 127 | HaljT/VBOZMgA8lb00cK7q6/UEndWfasOD7+9PhWSuZOuVHugarXl+C7OJ2P3mkB 128 | 4t2WV9vP+vJ/8YjnN6VC/aiYBsWnx7RbmmjoQ0NTdVYTYHCqnhATLJV8coLPM1e/ 129 | PRy6no4PZeD1UmRLoy2Hlq8DwByI4rMrun4q+G8FHkv6+aqeZtmLOunR2A/aINPr 130 | su34vgyE9Jvl6HXY9PH+mXYStAt4yPmUkcKOUn0gM26Cid84xZKMAAumY7ijbZaB 131 | rpAA/HOYx1h/Qt5zjAijd7tYEbr9G5nFpJ3xQzcdn3lAEEppCLGXF8SgC9yyRJo9 132 | oDJyh6NZJ0HSSfNeLS1kmi9IBwZQY6v1lnRnCiZ3M4Y2pZ8PV6Y1GDci4/6r97+g 133 | jdWsPcDghutgK0QchakI6mBdzF8gyvONT9TGbOs6UsVVrZlbXXDzWJbPpd9ONtMY 134 | RnOIY+pI/0Sp8tYWcXF47YaSvl/T9HGcXP28mYM37v2D56nlmSMYKC3KwwCuUGdK 135 | oR9p3OYmgL55tvtZsPgdpR7zrU3M6OtVP2pNI3MCufzyPaw5SS+r7dSXlTYYnZOB 136 | MAJnvOQxvls0hsHExgei9LMHBmm2tkXWm+m3C2MAHdyp6jU3qiASGLufKsbDxbXr 137 | b6EDw3QGREA0HSZM0Ik274OCXdipvmGhxTIRKsUb/rEL2/hQ/C/NZyOq7dFe2K0k 138 | KMtytMoutwMH7thrfs4E7zAcr+2yoRbIAzZL9WM7TwSSUnCZihhWiME8ugxDZWs+ 139 | +bMOhedzw2xx5ydZRmORUF7mCyP5MA4+GPe/O45Zbb90482Z4/73v9A5OWqLM0wz 140 | S+mETA0WVzs7wIuBzZCN4TeI1UPPfPIQxkPQL0+Fz61RGYYFHKTTvrWiSNsAzeD1 141 | 4RF+YS4t9utN0H7D4rhYsEWOynZV9Fiblh+OBlvQmct667r2FFx7hqj3g2vD7ycZ 142 | 416AEWoRcNCljwu0x2B7BzfnwsiNi3VvdzGX8UFfgXEGXmkTknqJ1VVYSN8tMLr3 143 | J6JxTpr1B+dSXFdPf4AlamU7j4WOUVzCH8ksUlniKxaQXhu2WSHr8pIZl53UYp5V 144 | Waw1mym95AGYW+jpD/JMgUNzoW7lQCr7i+Wrc6imtKeS/IHQ38tRcSocyCPshtso 145 | V5hilUDQx+8n96MZZFmzmsrpzGvpRjd67JOxBW0Bum8mJq20H/hrQbGJi3GS+Lsa 146 | jGkaojGEs2TAilu+GQpizEynnLVW+IrBZIePlrlRpiWn7iPc9aZYX3dGIejf/Au+ 147 | NIzN9fT/Mb3V5efOnDIJLHlNEiSxMVUqkX/juhLZjDkAduwqW5HOEUJFe5kAfHT8 148 | U5ksx7yyAxd48P8cA9Q4qcZuuWK24c/UNutUS8t/3mSOhQQBjtghXC+buGQeMOVD 149 | /pImhrqNRzFj4IVP9STRcGbqi+I9ZY80N4JQXEc4fnR6fxWSdq3p7NeSHz4PHGRi 150 | iuKTDW3rUb+QaaNZb8iICYg27i9h3ieiuzfUeaZj+QQ1xqfvRSytCzmhyfGPQiEI 151 | 479by0Lqf+q98ZS6Z1OwsIDHfAjNnOfm6shF9faB+0mjtIKwCXF45pTdyxD4a7xs 152 | ZbQJZi21weVWnbKKxa5JxGJX3q6hE4MFHpguDV5Ao+yINoMZFe7pYu6fiUWaz517 153 | pcfYHPCBDBOOBZo1ffZgKLKAGy5WzFvxIKmyqJhRoZLngKBjlylOQ7IDjG/+7FU3 154 | qSoqkI76kr6frp3QUXtWuZ0ekMCJOZiN+tnc0HQxSs3RH8SQJHTx+QfNN0QAkh5m 155 | wQF3VhmmeLC6MalHHkgN17hVd2ueE+9epAPj0tBmQNV524FcazD7MgmektGjeyif 156 | 0Xlmooi++0u0SwIKyVJ9I7oqGDfOzIX1VbdXM7ge3PUuIfjI8sb9EZjuaNFX5zvM 157 | ITT++lmY/qcTOaqrFQ7rkoppc7g+66sNHP4vnJ0DvMZJ5KkR15RxCsnO9HLOZWY8 158 | F8Ad6rTVfIr6U0afQC77JyaUzXMO2s9Ca7ws67FqboHcXStJHhDA9Qj3pFsfzSC5 159 | 2JxepM14rPBwFG+rsKDMLwyLXGEqx8cFIo+AV8a05ewguuErpciAybsxWE4ANo0u 160 | -----END RSA PRIVATE KEY----- 161 | """ 162 | 163 | config :joken, 164 | current_time_adapter: Joken.CurrentTime.Mock, 165 | default_signer: "s3cr3t", 166 | hs256: [ 167 | signer_alg: "HS256", 168 | key_octet: "test" 169 | ], 170 | hs384: [ 171 | signer_alg: "HS384", 172 | key_octet: "test" 173 | ], 174 | hs512: [ 175 | signer_alg: "HS512", 176 | key_octet: "test" 177 | ], 178 | rs256: [ 179 | signer_alg: "RS256", 180 | key_map: rsa_map_key 181 | ], 182 | rs384: [ 183 | signer_alg: "RS384", 184 | key_map: rsa_map_key 185 | ], 186 | rs512: [ 187 | signer_alg: "RS512", 188 | key_map: rsa_map_key 189 | ], 190 | pem_rs256: [ 191 | signer_alg: "RS256", 192 | key_pem: rsa_pem_key 193 | ], 194 | pem_es256: [ 195 | signer_alg: "ES256", 196 | key_pem: ec_pem_key 197 | ], 198 | pem_encrypted_rs256: [ 199 | signer_alg: "RS256", 200 | key_pem: rsa_encrypted_pem_key, 201 | passphrase: rsa_encrypted_passphrase 202 | ], 203 | pem_rs384: [ 204 | signer_alg: "RS384", 205 | key_pem: rsa_pem_key 206 | ], 207 | pem_rs512: [ 208 | signer_alg: "RS512", 209 | key_pem: rsa_pem_key 210 | ], 211 | ed25519: [ 212 | signer_alg: "Ed25519", 213 | key_openssh: """ 214 | -----BEGIN OPENSSH PRIVATE KEY----- 215 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 216 | QyNTUxOQAAACB/qscCIU645l8xh1J5l5PQmB9IBiSPMmzFywW7bFl5LAAAAKjOwjJQzsIy 217 | UAAAAAtzc2gtZWQyNTUxOQAAACB/qscCIU645l8xh1J5l5PQmB9IBiSPMmzFywW7bFl5LA 218 | AAAEBCfc95wRP1nAlJY4ahZBMUs2iN3eiSp48aNqjTfdhQsX+qxwIhTrjmXzGHUnmXk9CY 219 | H0gGJI8ybMXLBbtsWXksAAAAI3ZpY3Rvcm9saW5hc2NAbG9jYWxob3N0LmxvY2FsZG9tYW 220 | luAQI= 221 | -----END OPENSSH PRIVATE KEY----- 222 | """ 223 | ], 224 | public_pem: [ 225 | signer_alg: "RS256", 226 | key_pem: rsa_public_pem_key 227 | ], 228 | with_key_id: [ 229 | signer_alg: "HS256", 230 | key_octet: "secret", 231 | jose_extra_headers: %{"kid" => "my_key_id"} 232 | ], 233 | missing_config_key: [ 234 | signer_alg: "HS256" 235 | ], 236 | bad_algorithm: [ 237 | signer_alg: "any algorithm", 238 | key_octet: "secret" 239 | ] 240 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "test/support" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /guides/asymmetric_cryptography_signers.md: -------------------------------------------------------------------------------- 1 | # Asymmetric cryptography signers 2 | 3 | It is common to divide cryptography in three categories: 4 | 5 | 1. Symmetric cryptography (also called [symmetric-key algorithms](https://en.wikipedia.org/wiki/Symmetric-key_algorithm)): 6 | - Clear text is passed to an algorithm that uses a key for encrypting and decrypting to generate cipher text; 7 | - Examples include: AES (Advanced Encryption Standard), Blowfish, DES (Data Encryption Standard), etc; 8 | - In the BEAM ecosystem, it is represented by the `:crypto.supports()[:ciphers]`. 9 | 2. Asymmetric cryptography (also called [public-key cryptography](https://en.wikipedia.org/wiki/Public-key_cryptography)): 10 | - It uses a pair of keys where one is private (only known to the owner) and the other is public; 11 | - Clear text is passed to an algorithm plus the private key and generates a cipher text; 12 | - The cipher text can be verified by the public key, that is, we can check that it was generated by the private key. 13 | 3. Hash cryptography (also called [one-way functions](https://en.wikipedia.org/wiki/One-way_function)): 14 | - Clear text is passed to a function that may or may not receive a key to generate irreversible cipher text. 15 | 16 | In the [JWA](https://tools.ietf.org/html/rfc7518#section-3) specification we have algorithms that use both symmetric and asymmetric cryptography functions. Let's see them (taken from the specification): 17 | 18 | | "alg" Param Value | Digital Signature or MAC Algorithm | Implementation requirements | 19 | | ----------------- | ---------------------------------------------- | --------------------------- | 20 | | HS256 | HMAC using SHA-256 | Required | 21 | | HS384 | HMAC using SHA-384 | Optional | 22 | | HS512 | HMAC using SHA-512 | Optional | 23 | | RS256 | RSASSA-PKCS1-v1_5 using SHA-256 | Recommended | 24 | | RS384 | RSASSA-PKCS1-v1_5 using SHA-384 | Optional | 25 | | RS512 | RSASSA-PKCS1-v1_5 using SHA-512 | Optional | 26 | | ES256 | ECDSA using P-256 and SHA-256 | Recommended+ | 27 | | ES384 | ECDSA using P-384 and SHA-384 | Optional | 28 | | ES512 | ECDSA using P-521 and SHA-512 | Optional | 29 | | PS256 | RSASSA-PSS using SHA-256 and MGF1 with SHA-256 | Optional | 30 | | PS384 | RSASSA-PSS using SHA-384 and MGF1 with SHA-384 | Optional | 31 | | PS512 | RSASSA-PSS using SHA-512 and MGF1 with SHA-512 | Optional | 32 | 33 | (removed the none algorithm we don't support\*\*) 34 | 35 | Besides the HSxxx family of algorithms, all others use asymmetric cryptography. 36 | 37 | ## Using asymmetric algorithms 38 | 39 | What is nice about public-key cryptography is that you don't need to "share a key" between parties. The one with the private key can generate tokens and publish his public key online so that anybody can check that token was generated by the owner of the private key. 40 | 41 | In Joken that usually means a private key can be used to call `Joken.encode_and_sign` and `Joken.verify`. If only have the public key, then you can only call `Joken.verify`. 42 | 43 | There are some specifications that use this: OpenID Connect being one very famous. It specifies that authentication servers must publish their keys in JWK (JSON Web Key) format on a public URL. Here are some examples: 44 | 45 | - Google: [https://www.googleapis.com/oauth2/v3/certs](https://www.googleapis.com/oauth2/v3/certs) 46 | - Microsoft: [https://login.microsoftonline.com/common/discovery/v2.0/keys](https://login.microsoftonline.com/common/discovery/v2.0/keys) 47 | 48 | We have a Joken Hook that can fetch these keys, turn them into signers and verify tokens. This is [JokenJwks](https://github.com/joken-elixir/joken_jwks). 49 | 50 | ## KEY formats 51 | 52 | Different algorithms use different key formats. For example, RSA is based on huge prime numbers arithmetic. Its keys contain data about these prime numbers and other variables in the RSA function specification. 53 | 54 | Elliptic curve cryptography is based on elliptic curve arithmetic. Its keys represent the function that generates infinite points on a curve specification. 55 | 56 | Because of those differences, we usually have a container for each key type. There is a specification for *how to represent these keys in JSON format*. That is the [JSON Web Key](https://tools.ietf.org/html/rfc7517) specification. It has a JSON format for each type of key. 57 | 58 | We recommend reading on the appendix examples for each type of key. [Here](https://tools.ietf.org/html/rfc7517#appendix-A) is the appendix of the JSON Web Key specification with examples for public and private RSA and EC keys. 59 | 60 | Let's see some examples on parsing asymmetric RSA keys with Joken: 61 | 62 | ### RSxxx keys 63 | 64 | This algorithm uses the RSASSA-PKCS1-v1_5 that uses SHA2 hash algorithms. The base for this algorithm is the RSA public key standard. So, to use this algorithm we need a pair of RSA keys. There are many ways to generate these keys in different environments and is outside the scope of this library. Here is one of these ways just for an example: 65 | 66 | ```shell 67 | ➜ joken git:(main) openssl genrsa -out mykey.pem 4096 68 | Generating RSA private key, 4096 bit long modulus (2 primes) 69 | .............++++ 70 | ...........................................................................................................++++ 71 | ``` 72 | 73 | This will generate a private RSA key in PEM (privacy enhanced mail) format on a file named mykey.pem. With this key we can *sign* and *verify* a token signature. 74 | 75 | To use it with Joken we can call one of the `Joken.Signer.create` variants: 76 | 77 | 1. With the RAW PEM contents: 78 | 79 | ```elixir 80 | signer = Joken.Signer.create("RS256", %{"pem" => pem_contents}) 81 | ``` 82 | 83 | 2. With the pem file in the configuration: 84 | 85 | ```elixir 86 | use Mix.Config 87 | 88 | config :joken, 89 | my_rsa_key: [ 90 | signer_alg: "RS256", 91 | key_pem: """ 92 | -----BEGIN RSA PRIVATE KEY----- 93 | MIICWwIBAAKBg 94 | ... 95 | -----END RSA PRIVATE KEY----- 96 | """ 97 | ] 98 | 99 | signer = Joken.Signer.parse_config(:my_rsa_key) 100 | ``` 101 | 102 | 3. With the key in JWK format: 103 | 104 | ```elixir 105 | # example of key parameters from https://tools.ietf.org/html/rfc7517#appendix-A.1 106 | # This is for demonstration purposes. We don't allow those parameters in the map like 107 | # "alg", "kid", "kty". Although they are part of the JWK Set specification. 108 | 109 | key = %{ 110 | "alg" => "RS256", 111 | "e" => "AQAB", 112 | "kid" => "2011-04-29", 113 | "kty" => "RSA", 114 | "n" => "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw" 115 | } 116 | 117 | signer = Joken.Signer.create(key) 118 | ``` 119 | 120 | 4. With the key in JWK format on the config: 121 | 122 | ```elixir 123 | use Mix.Config 124 | 125 | config :joken, 126 | my_rsa_public_key: [ 127 | signer_alg: "RS256", 128 | key_map: %{ 129 | "alg" => "RS256", 130 | "e" => "AQAB", 131 | "kid" => "2011-04-29", 132 | "kty" => "RSA", 133 | "n" => "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw" 134 | } 135 | ] 136 | 137 | signer = Joken.Signer.parse_config(:my_rsa_public_key) 138 | ``` 139 | 140 | ### Other algorithms 141 | 142 | Joken has a test suite that runs all supported algorithms. Please have a look at other algorithms from our test suite. 143 | 144 | Also, check `:erlang-jose` documentation about keys: https://github.com/potatosalad/erlang-jose/blob/master/examples/KEY-GENERATION.md 145 | -------------------------------------------------------------------------------- /guides/common_use_cases.md: -------------------------------------------------------------------------------- 1 | # JWT Common use cases 2 | 3 | This is just a collection of common examples of working with tokens using Joken's API. 4 | 5 | ## JWT simplest configuration 6 | 7 | We will start from the simplest configuration possible: 8 | 9 | ``` elixir 10 | defmodule Token do 11 | use Joken.Config 12 | end 13 | ``` 14 | 15 | With this configuration you can: 16 | 17 | - Generate a token with `aud`, `iss`, `exp`, `jti`, `iat`, `nbf`. 18 | - Validate a token with those claims. 19 | - Use a default signer configuration. 20 | 21 | ## Custom static `iss` claim 22 | 23 | ``` elixir 24 | defmodule Token do 25 | use Joken.Config 26 | 27 | @impl true 28 | def token_config do 29 | default_claims(skip: [:iss]) 30 | |> add_claim("iss", fn -> "My issuer" end, &(&1 == "My issuer")) 31 | end 32 | end 33 | ``` 34 | 35 | Since `iss` is a default claim, we can pass that value to default_claims directly: 36 | 37 | ``` elixir 38 | defmodule Token do 39 | use Joken.Config 40 | 41 | @impl true 42 | def token_config do 43 | default_claims(iss: "My custom issuer") 44 | end 45 | end 46 | ``` 47 | 48 | ## Custom dynamic `aud` claim 49 | 50 | In this scenario we don't want a function for generating the claim value. We will generate it according to some business context. So, we skip static generation BUT we provide the claim validation all the same. 51 | 52 | ``` elixir 53 | defmodule Token do 54 | use Joken.Config 55 | 56 | @impl true 57 | def token_config do 58 | default_claims(skip: [:aud]) 59 | |> add_claim("aud", nil, &(&1 in ["My audience", "Your audience", "Her audience"])) 60 | end 61 | end 62 | 63 | # Defined at call site 64 | Token.generate_and_sign(%{"aud" => "My audience"}) 65 | ``` 66 | 67 | ## Custom validation cross claims 68 | 69 | Sometimes you need the value of another claim to validate some other claim. For example, you need the value of the role claim to validate the audience. That is fine. The validate function can receive up to 3 arguments: 1) the claim value, 2) &1, all the claims, 3) &1, &2, context. 70 | 71 | ``` elixir 72 | defmodule Token do 73 | use Joken.Config 74 | 75 | @impl true 76 | def token_config do 77 | default_claims(skip: [:aud]) 78 | |> add_claim("aud", nil, &validate_audience/2) 79 | end 80 | 81 | defp validate_audience(value, claims) do 82 | case claims["role"] do 83 | "admin" -> 84 | "http://myserver.com/admin" 85 | 86 | "user" -> 87 | "http://myserver.com" 88 | end 89 | end 90 | end 91 | ``` 92 | 93 | ## Signer fetched from another server 94 | 95 | It is common to use OpenID Connect authentication servers to federate login. In this scenario, the signer configuration is usually available in what is called a well known URL. 96 | 97 | This URL has a JWKS configuration. This tells the world that tokens generated by these servers can be validated with these keys. Normally, the key id is a claim in the token header. 98 | 99 | We can use Joken easily in this scenario. We can abstract all the logic of fetching the signer configuration from the URL in a Hook and configure our claims validation without generation. Let's see an example: 100 | 101 | ``` elixir 102 | defmodule Token do 103 | use Joken.Config, default_signer: nil # no signer 104 | 105 | # This hook implements a before_verify callback that checks whether it has a signer configuration 106 | # cached. If it does not, it tries to fetch it from the jwks_url. 107 | add_hook(JokenJwks, jwks_url: "https://someurl.com") 108 | 109 | @impl true 110 | def token_config do 111 | default_claims(skip: [:aud, :iss]) 112 | |> add_claim("roles", nil, &(&1 in ["admin", "user"])) 113 | |> add_claim("iss", nil, &(&1 == "some server iss")) 114 | |> add_claim("aud", nil, &(&1 == "some server aud")) 115 | end 116 | end 117 | 118 | # We can call this by 119 | Token.verify_and_validate(jwt) 120 | ``` 121 | 122 | ## Generate a token with the user id as subject 123 | 124 | Another common need is to generate a token with some specific data. We can even validate that data format when we receive a token. As an example, we will validate the claim "sub" as being a valid UUID but will not generate this value in Joken. This is just an example of a dynamic claim value. 125 | 126 | ``` elixir 127 | defmodule Token do 128 | use Joken.Config 129 | 130 | @impl true 131 | def token_config do 132 | default_claims() 133 | |> add_claim("sub", nil, &is_valid_uuid/1) 134 | end 135 | 136 | # ... implementation of UUID validation 137 | end 138 | 139 | # We can pass the subject as extra claims 140 | Token.generate_and_sign(%{"sub" => "uuid"}) 141 | ``` 142 | -------------------------------------------------------------------------------- /guides/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ## Token configuration 4 | 5 | One of Joken's basic concepts is a map of configuration. This map has binary keys that are the claims names and `Joken.Claim` instances with what to do during generation or validation. 6 | 7 | Here is an example: 8 | 9 | ``` elixir 10 | # Empty token configuration 11 | token_config = %{} 12 | 13 | # Let's create a Joken.Claim 14 | iss = %Joken.Claim{ 15 | generate: fn -> "My issuer" end, 16 | validate: fn claim_val, claims, context -> claim_val == "My issuer" end 17 | } 18 | 19 | # Now let's add it to our token config 20 | token_config = Map.put(token_config, "iss", iss) 21 | ``` 22 | 23 | This configuration map is referred to as `token_config`. Since creating it is cumbersome, we provide some helpers: 24 | 25 | ``` elixir 26 | # Same result as the first example: 27 | token_config = %{} |> Joken.Config.add_claim("iss", fn -> "My issuer" end, &(&1 == "My issuer")) 28 | ``` 29 | 30 | You need at least one of the functions (validate or generate). One example of leaving one of them empty is when you are only validating tokens. In this case you might leave generate functions empty. 31 | 32 | With your `token_config` created, you can pass it to functions like: `Joken.generate_claims/3` or `Joken.validate/4`. 33 | 34 | ## Signer configuration 35 | 36 | Signer is an instance of `Joken.Signer`. You can create one like this: 37 | 38 | ``` elixir 39 | signer = Joken.Signer.create("HS256", "my secret") 40 | ``` 41 | 42 | This is an explicit signer creation. You can configure a signer in mix `config.exs` too. Please see the docs on `Joken.Signer` for the accepted parameters. 43 | 44 | ## Module approach 45 | 46 | In Joken 2.0 you can encapsulate all your token logic in a module with `Joken.Config`. You do that like this: 47 | 48 | ``` elixir 49 | defmodule MyAppToken do 50 | use Joken.Config 51 | 52 | # other functions here... 53 | end 54 | ``` 55 | 56 | This is the recommended approach. With this macro you get some generated functions that pass your `token_config` automatically to Joken's functions. It also implements the `Joken.Hooks` behaviour so you can override any of its callbacks. Also, by default, it will look for a signer from mix config with the `default_signer` key. 57 | 58 | Let's see this in more depth below. 59 | 60 | ## Claims generation and validation 61 | 62 | Let's start with an example: 63 | 64 | ``` elixir 65 | defmodule MyApp.Token do 66 | use Joken.Config 67 | end 68 | ``` 69 | 70 | With this configuration, you get: 71 | 72 | - A key configuration named `:default_signer` 73 | - Your `token_config` will be created by `Joken.Config.default_claims/1` 74 | 75 | So, if you call `MyApp.Token.generate_and_sign/2` **and** you have a key configured with the value `:default_signer` you'll get a token with: 76 | 77 | - `exp`: defaults to 2 hours with validation 78 | - `iat`: defaults to `Joken.current_time/0` 79 | - `nbf`: defaults to `Joken.current_time/0` with validation 80 | - `iss`: defaults to "Joken" with validation 81 | - `aud`: defaults to "Joken" with validation 82 | - `jti`: defaults to `Joken.generate_jti/0` 83 | 84 | It is important to notice that this configuration is used for claims we want to either generate dynamically (like all time based claims) or validate (like `iss` claim that we want to ensure is the same we use to generate our tokens). 85 | 86 | ### Overriding `token_config/0` 87 | 88 | You can customize token generation and validation by overriding the function `token_config/0` in your module. Example: 89 | 90 | ``` elixir 91 | defmodule MyApp.Token do 92 | use Joken.Config 93 | 94 | def token_config do 95 | %{} 96 | |> add_claim("my_key", fn -> "My custom claim" end, &(&1 == "My custom claim")) 97 | end 98 | end 99 | 100 | {:ok, token, claims} = MyApp.Token.generate_and_sign() 101 | 102 | {:ok, claims} = MyApp.Token.verify_and_validate(token) 103 | 104 | claims = %{"my_key" => "My custom claim"} 105 | ``` 106 | 107 | Please see `Joken.Config` docs for more info on the generated callbacks. 108 | -------------------------------------------------------------------------------- /guides/custom_header_arguments.md: -------------------------------------------------------------------------------- 1 | # Custom header arguments 2 | 3 | By default, a header in a token is only meant for static information. This information is used for signature verification. 4 | 5 | Common extra claims in the header might be the key id used to sign the contents, crypto algorithms and so on. 6 | 7 | If you need to generate extra header claims, you can do that in one of two ways: 1. use a custom signer or 2. change the application configuration. 8 | 9 | An example of creating a custom signer with extra header claims: 10 | 11 | ``` elixir 12 | test "can set key id on signer" do 13 | key_id = "kid" 14 | signer = Signer.create("HS256", "secret", %{"kid" => key_id}) 15 | {:ok, token, _claims} = Joken.encode_and_sign(%{}, signer) 16 | assert %{"kid" => ^key_id, "alg" => "HS256"} = Joken.peek_header(token) 17 | end 18 | ``` 19 | 20 | Another example using the application configuration: 21 | 22 | ``` elixir 23 | # config/config.exs 24 | config :joken, signer_with_key_id: [ 25 | signer_alg: "HS256", 26 | key_octet: "secret", 27 | jose_extra_headers: %{"kid" => "my_key_id"} 28 | ], 29 | 30 | # test/sometest.exs 31 | test "can parse with key_id" do 32 | {:ok, token, _claims} = Joken.encode_and_sign(%{}, Signer.parse_config(:signer_with_key_id)) 33 | assert %{"kid" => "my_key_id", "alg" => "HS256"} = Joken.peek_header(token) 34 | end 35 | ``` 36 | 37 | -------------------------------------------------------------------------------- /guides/introduction.md: -------------------------------------------------------------------------------- 1 | # Joken Overview 2 | 3 | Joken is a JWT (JSON Web Token) library centered on 4 core operations: 4 | 5 | - Generating claims: for instance, time based claims. 6 | - Signing: using a `Joken.Signer`. 7 | - Verifying: also using a `Joken.Signer`. 8 | - Validating: running custom validations for received claims. 9 | 10 | These core functions are not coupled which allows you to configure Joken for only what you need: verifying and validating incoming tokens, generating tokens for other consumers or both. 11 | 12 | It is based upon the awesome [`erlang-jose`](https://github.com/potatosalad/erlang-jose/). Besides having a friendlier Elixir API we include some extras: 13 | 14 | - Simple key configuration. We provide optional built-in support with Elixir's `Mix.Config` system. See our configuration guide for more details; 15 | - Portable configuration using `Joken.Claim`; 16 | - Well-organized token logic by including the `use Joken.Config` statement in your own module; 17 | - Built-in claim generation and validation. `erlang-jose` is responsible only for signing and verifying token signatures. Here we provide extra tools for claims validation and generation; 18 | - Better error handling. We provide `ExUnit` like error messages for claim validation and configuration errors; 19 | - High-quality performance analysis for ensuring this hot-path in APIs won't be your bottleneck. Please see our performance documentation to learn more; 20 | - Good defaults. Joken comes with chosen good defaults for parsing JSON (Jason) and generating claims; 21 | - Extensible Joken functionality with hooks. All core actions in Joken have a corresponding hook for extending its functionality. See our hooks guide. 22 | 23 | ## JWT algorithms 24 | 25 | Joken supports all algorithms that JOSE supports. That includes: 26 | 27 | - All HS, RS, ES, PS signing algorithms. 28 | - All Edwards algorithms (Ed25519, Ed25519ph, Ed448, Ed448ph) 29 | 30 | See [jose JWS algorithm support](https://github.com/potatosalad/erlang-jose#json-web-signature-jws-rfc-7515) for more information. 31 | 32 | ## Usage 33 | 34 | As easy as: 35 | 36 | A key configuration: 37 | 38 | ``` elixir 39 | # config/dev.exs 40 | config :joken, default_signer: "secret" 41 | ``` 42 | 43 | Optionally, a signer instance: 44 | 45 | ``` elixir 46 | signer = Joken.Signer.create("HS256", "secret") 47 | ``` 48 | 49 | A token module: 50 | 51 | ``` elixir 52 | # lib/myapp/token.ex 53 | defmodule MyApp.Token do 54 | use Joken.Config 55 | end 56 | ``` 57 | 58 | Then, just use your module :) 59 | 60 | ``` elixir 61 | {:ok, token, claims} = MyApp.Token.generate_and_sign() 62 | 63 | extra_claims = %{"user_id" => "some_id"} 64 | token_with_default_plus_custom_claims = MyApp.Token.generate_and_sign!(extra_claims) 65 | 66 | {:ok, claims} = MyApp.Token.verify_and_validate(token) 67 | 68 | # Example with a different key than the default 69 | claims = MyApp.Token.verify_and_validate!(token, another_key) 70 | ``` 71 | 72 | Or with explicit signer: 73 | 74 | ``` elixir 75 | signer = Joken.Signer.create("HS256", "secret") 76 | 77 | {:ok, token, claims} = MyApp.Token.generate_and_sign(%{}, signer) 78 | 79 | extra_claims = %{"user_id" => "some_id"} 80 | token_with_default_plus_custom_claims = MyApp.Token.generate_and_sign!(extra_claims, signer) 81 | 82 | {:ok, claims} = MyApp.Token.verify_and_validate(token, signer) 83 | ``` 84 | 85 | The default is to use HS256 with the configured binary as the key. It will generate: 86 | 87 | - `aud`: defaults to "Joken" 88 | - `iss`: defaults to "Joken" 89 | - `jti`: defaults to `&Joken.generate_jti/0` 90 | - `exp`: defaults to 2hs 91 | - `nbf`: defaults to current time 92 | - `iat`: defaults to current time 93 | 94 | Everything is customizable in your token module. Please, see our configuration guide for all configuration options. 95 | -------------------------------------------------------------------------------- /guides/migration_from_1.md: -------------------------------------------------------------------------------- 1 | # Migrating from Joken 1.0 2 | 3 | Joken 2.0 tries to fix several issues we had with the 1.x series. Some of those issues were: 4 | 5 | 1. **Initialization of the `json` client in JOSE** 6 | 7 | The JSON adapter needed to be set every time. This is now an application configuration. 8 | 9 | 2. **Confusion between dynamic and static claim value generation** 10 | 11 | Using dynamic over static claims was confusing as this feature was thrown on after version 1.0. Now it is explicit that all claim generation must be done by providing a function that is called at *token generation time*. You are free to implement this token generation function to return static or dynamic values. 12 | 13 | 3. **Static claims** 14 | 15 | There was another confusing feature about including static claim values. For example, the API was awkward if you wanted to pass the user id to your token generation function. Now you can pass a map of claims to be added to the token. This avoids the burden of handling all possible use cases. You can still validate any claim. 16 | 17 | 4. **Debugging** 18 | 19 | The error messages were not very instructive, often requiring a deep understanding of `Joken` in order to debug. We've made great improvements in this area. 20 | 21 | In order to overcome most of these issues, Joken 2.0 breaks backward compatibility in several ways. We believe it was worth it. We brought in: 22 | 23 | - Module configuration through `Joken.Config` which makes it really simple to configure your claims and have it encapsulated by default; 24 | - Hook system through `Joken.Hooks` to extend Joken's features with simple plug-like semantics; 25 | - Performance analysis with a faster implementation--faster than other token libraries in the elixir community; 26 | - Improved error messages; 27 | - Ready for future development without breaking the API again (options in claims); 28 | - Improved testability with mocking current time implementation; 29 | - A Jason adapter for JOSE; 30 | - More signer configuration options; 31 | 32 | ## Migrating 33 | 34 | Joken 2 has two approaches: one similar to Joken 1.x and another one using `Joken.Config`. Let's talk about them separately. 35 | 36 | ### Keeping close to Joken 1.x style 37 | 38 | Joken 1.x was based on configuring the `Joken.Token` struct and then calling `sign/2` or `verify/3`. Joken 2.0 omits the `Joken.Token` struct for several reasons: the name of the module was confusing and it had some side-effects like setting the JSON module on JOSE. 39 | 40 | We still can build a token configuration and pass it to similar functions `sign` and `verify`. The token configuration is now a simple map of claim keys that must be binaries to an instance of `Joken.Claim`. This struct holds the functions to operate on claims. 41 | 42 | So, with this approach, let's compare the same configuration in both versions: 43 | 44 | ``` elixir 45 | # Joken 1.x 46 | import Joken 47 | 48 | %Joken.Token{} # empty configuration 49 | |> with_json_module(Poison) # no built-in Jason module 50 | |> with_validation("some_claim", &(&1 == "some value")) 51 | |> sign(hs256("secret")) # to change the signer for test is cumbersome 52 | |> get_compat() # compact is not a JWT terminology in any way 53 | ``` 54 | 55 | ``` elixir 56 | # Joken 2.0 57 | import Joken.Config # more specific 58 | 59 | token_config = 60 | default_claims() 61 | |> add_claim("some_claim", nil, &(&1 == "some value")) # explicit no generate function 62 | 63 | Joken.generate_and_sign() 64 | 65 | ## on your config.exs 66 | config :joken, default_signer: "secret" 67 | 68 | ## ... or if you want to keep the explicit signer creation 69 | Joken.generate_and_sign(token_config, nil, Joken.Signer.create("HS256", "secret")) 70 | ``` 71 | 72 | ### Using the new encapsulated module configuration 73 | 74 | The same example as above can be written differently in Joken 2. We think this is better for isolating token related logic in a single module. Here is how it could be written: 75 | 76 | ``` elixir 77 | defmodule MyToken do 78 | use Joken.Config 79 | 80 | @impl true 81 | def token_config do 82 | default_claims() 83 | |> add_claim("some_claim", nil, &(&1 == "some value")) 84 | end 85 | end 86 | 87 | # to use it all you need is: 88 | MyToken.generate_and_sign() 89 | ``` 90 | 91 | You can also add custom token logic in that module like persisting it, adding an `authenticate` function that receives a user and a token or something similar. You could even turn it into an authentication plug adding a `call(conn, opts)`. 92 | 93 | Another advantage of this approach is that you can add hooks to your processing. Check the hooks guide for more information. 94 | -------------------------------------------------------------------------------- /guides/signers.md: -------------------------------------------------------------------------------- 1 | # Signers 2 | 3 | A signer is the combination of a "key" and an algorithm. That is all we need to sign and verify tokens. In JWT's vocabulary: a JWS (JSON Web Signing) with a JWK (JSON Web Key). 4 | 5 | For each algorithm, a specific key format is expected. HS algorithms expect an octet key (a "password" like key), RS algorithms expect an RSA key and so on. 6 | 7 | ## Configuration 8 | 9 | A signer is configured using the following parameters: 10 | 11 | - **signer_alg** : "HS256", "HS384" and so on. 12 | - **key_pem** : a binary containing a key in PEM encoding format. 13 | - **key_openssh** : a binary containing a private key in OpenSSH encoding format. 14 | - **key_map** : a map with the raw parameters of the key. 15 | - **key_octet** : a binary used as the password for HS algorithms only. 16 | - **key_index** : the index of the key on a pem or OpenSSH key set defaults to `0`. 17 | 18 | Let's see some examples: 19 | 20 | ``` elixir 21 | # RS256 with a PEM encoded key 22 | [ 23 | signer_alg: "RS256", 24 | key_pem: """ # You can pass a PEM encoded key... See below for all options. 25 | -----BEGIN RSA PRIVATE KEY----- 26 | MIIC...xcYw== 27 | -----END RSA PRIVATE KEY----- 28 | """ 29 | ] 30 | 31 | # HS512 with an octet key 32 | [ 33 | signer_alg: "HS512", 34 | key_octet: "a very random string" 35 | ] 36 | ``` 37 | 38 | ## Octet keys 39 | 40 | HS algorithms (HS256, HS384, HS512) use a simple binary for their key. You can only use `key_octet` with HS algorithms. 41 | 42 | There is another octet key type (OKP -> octet key pair) for use with Edwards algorithms but normally we use OpenSSH private key encoding or a map with the octets so it is not mentioned here. 43 | 44 | ## All other keys 45 | 46 | Besides HS algorithms, we have several types of keys. Each type has its own set of parameters. For example, here is a full list of RSA parameters in an RSA private key: 47 | 48 | ``` elixir 49 | rsa_map_key = %{ 50 | "d" => 51 | "A2gHIUmJOzRGvklIA2S8wWayCXnF8NYAhOhu7woSwjioO3HRzvd3ptegSKDpPfABJuzhy7y08ug5ZcyFbN1hJBVY8NwNzpLSUK9wmXekrbTG9MT76NAiQTxV6fYK5DXPF4Cp0qghBt-tq0kQNKx4q9QEzLb9XonmXE2a10U8EWJIs972SFGhxKzf6aq6Ri7UDK607ngQyEhVmGxr3gDJLAGQ5wOap5NYIL2ufI5FYqH-Sby_Qk7299b-w4B0fl6u8isR8OlpwMLVnD-oqOBPH-65tE82hxPV0QbSmyzmg9hlVVinJ82YRBkbcu-XG9XXOhUqJJ7kafQrYkQx6BiFKQ", 52 | "dp" => 53 | "Useg361ca8Aem1TToW8AfjOLAAEqkkR48UPMSS2Le9D4YFtAb_ud5CK2IevYl0R-4afXUzIoeiNRg4bOTAWmTwKKlmAp4B5GzlbPzAPhwQRCxzs5MiW0K-Nw30blBLWlJYDAnVEr3T3rqtgzXFLMhR5AHqM4VhWQK7QaxgaW7TE", 54 | "dq" => 55 | "yueW-DmyJULJlJckFXfkivSO_X1sjQurDwDfyFLAnrvgy2EqJ-iq0gBVySMGw2CgeSQegTmuKinF4anL0wy85BK8tgxDULVOpjls4ej8ZQnJ2RVEjdxZLjKh-2yw-v6mbn7goko98nkRCBYMdDUBHNVcaY9bA8kdBWi-K6DgW2E", 56 | "e" => "AQAB", 57 | "kty" => "RSA", 58 | "n" => 59 | "xnAUUvtW3ftv25jCB-hePVCnhROqH2PACVGoCybdtMYTl8qVABAR0d6T-BRzVhJzz0-UvBNFUQyVvKAFxtbQUZN2JgAm08UJrDQszqz5tTzodWexODdPuoCaWaWge_MZGhz5PwWd7Jc4bPAu0QzSVFpBP3CovSjv48Z2Eq0_LHXVjjX_Az-WaUh94mXFyAxFI_oCygtT-il1-japS3cXJJh0WddT3VKEBRYHmxDJd_LYE-KXQt3aTDhq0vI9sG2ivtFj0dc3w_YBdr4hlcr42ujSP3wLTPpTjituwHQhYP4j-zqu7J3FYaIxU4lkK9Y_DP27RxffFI9YDPJdwFkNJw", 60 | "p" => 61 | "5cMQg_4MrOnHI44xEs6Jyt_22DCvw3K-GY046Ls50vIf2KlRALHI65SPKfVFo5hUuHkBuWnQV46tHJU0dlmfg4svPMm_581r59yXeI8W6G4FlsSiVyhFO3P5Q5ubVs7MNaqhvaqqPqR14cVvHSqjwX5jGuGAVuLhnOhZGbtb7_U", 62 | "q" => 63 | "3RlGNrCRU-yV7TTikKJVJCIpe8vgLBkHQ61iuICd8AyHa4sXICgf2YBFgW8CAJOHKIp8g_Nl94VYpqWvN1YVDB7sFUlRpJL2yXvTKxDzUwtM5pf_D1O6lGEMQBRY-buhZHmPf5qG93LnsSqm5YOZGpZ6t6gHtYM9A6JOIgwsYys", 64 | "qi" => 65 | "kG5Stetls18_1fvQx8rxhX2Ais0Xg0gLDUjpE_9TYcb-utq79HVKOQ_2PJGz09hQ_teqnhXhgGMubqaktl6UOSJr6B4JgcAY7yU-34EuSxp8uKLix9BVsF2cpiC4ADhjLKP9c7IQ7X7zfs336_Reb8fh9G_zRdwEfmqFy7m28Lg" 66 | } 67 | ``` 68 | 69 | This map is in the format defined by JWK spec. Although you CAN use this format for configuring RSA keys, it is most common to use other formats like PEM (Privacy Enhanced Mail) encoded. 70 | 71 | ## PEM - Privacy Enhanced Mail 72 | 73 | Please, don't mind the name... This is just history being unfair. If you are curious, take a look at Wikipedia's article on PEM [here](https://en.wikipedia.org/wiki/Privacy-enhanced_Electronic_Mail). 74 | 75 | Joken brings a facility for setting a PEM key. Just use the config option `key_pem`. Paste your PEM contents there and that's it. Example: 76 | 77 | ``` elixir 78 | [ 79 | signer_alg: "RS512", 80 | key_pem: """ 81 | -----BEGIN RSA PRIVATE KEY----- 82 | 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== 83 | -----END RSA PRIVATE KEY----- 84 | """ 85 | ``` 86 | 87 | If you are creating a signer explicitly, you need to pass the PEM in a map with the key PEM. Example: 88 | 89 | ``` elixir 90 | signer = Joken.Signer.create("RS512", %{"pem" => key_pem}) 91 | ``` 92 | 93 | Inside a PEM you can put several things. It may hold more than just a private key. For Joken, though, it might get a bit funky if you pass a PEM with several things in it. After all, we only need to read a key from it. Joken is not a library meant to be fully compliant with the PEM standard. 94 | 95 | ## Private vs Public keys 96 | 97 | Many people ask why should you use an algorithm with a private/public key pair. The beauty of it is that if you generate your token with a private key anybody with a public key can verify its integrity but they can't generate a token the same way. So, the design here is that if you need another party to verify tokens (say, a client of your server) you can send it the public key and it will validate tokens generated by your private key. The other way around is not true though. 98 | 99 | This is the main benefit. And sure is a great one :) 100 | 101 | So, if you only call verify functions, you don't need the private key. But, if you call sign functions, you will need the private key. 102 | 103 | One thing that might seem confusing is that with some private keys you can **SIGN** and **VERIFY**. WTH??? Yep, some private keys contain the public key too inside of them (for example with RSA keys). So, you can sign and verify, both with the same key. 104 | 105 | ## Benchmarks 106 | 107 | It is preferable to use the PEM format instead of passing a map of keys with all the values. Performance-wise it is just faster. You can run the benchmarks in the benchmarks folder. 108 | 109 | Why is this way faster? Well, to use the key we need to parse it into the erlang expected type that is not PEM nor JWKs maps. BUT, erlang can handle PEMs natively while it can't handle JWKs. 110 | 111 | ## Dynamic signers 112 | 113 | All functions that receive a key argument may be passed an instance of a `Joken.Signer` in its place. This is a convenience for when you need dynamic configuration such as when you are retrieving the key from an endpoint. 114 | 115 | Example: 116 | 117 | ``` elixir 118 | defmodule MyCustomAuth do 119 | use Joken.Config 120 | end 121 | 122 | # Using default signer configuration 123 | MyCustomAuth.generate_and_sign() 124 | 125 | # Explicit Signer instance 126 | MyCustomAuth.generate_and_sign(%{"some" => "extra claim"}, Joken.Signer.create("HS512", "secret")) 127 | ``` 128 | -------------------------------------------------------------------------------- /guides/testing.md: -------------------------------------------------------------------------------- 1 | # Testing your app with Joken 2 | 3 | One common hurdle with testing tokens is that they almost always contain time sensitive claims. How can you automate the expiration of your tokens for testing? 4 | 5 | One possible solution to this problem is to have a different token configuration for your tests. This works but is not advisable since your production code will run a different path than your test code. Your tests might pass, but things can go wrong in production. 6 | 7 | ## Joken time 8 | 9 | We have introduced an adapter for producing any time-sensitive claim values. `Joken.current_time/0` looks for the implementation it will use in the configuration. This is the adapter pattern that helps you mock time if you need to. 10 | 11 | The default implementation we use is `Joken.CurrentTime.OS`. It uses `DateTime` to fetch current time in seconds. 12 | 13 | In our tests we have a `Joken.CurrentTime.Mock` that can freeze or advance time in the way that we want. Please look in our test base for one possible solution. 14 | 15 | We don't ship `Joken.CurrentTime.Mock` in the library as this is only one possible way of solving this. If you already have a time mocking mechanism in your app, you can make Joken use it with: 16 | 17 | ```elixir 18 | config :joken, current_time_adapter: MyTimeMock 19 | ``` 20 | 21 | All it needs is to implement the function `current_time/0`. 22 | 23 | ### Behaviour 24 | 25 | It is also worth mentioning that `Joken.CurrentTime` is a behaviour so you can use mocking libraries like `mox`. 26 | -------------------------------------------------------------------------------- /lib/joken.ex: -------------------------------------------------------------------------------- 1 | defmodule Joken do 2 | @moduledoc """ 3 | Joken is a library for working with standard JSON Web Tokens. 4 | 5 | It provides 4 basic operations: 6 | 7 | - Verify: the act of confirming the signature of the JWT; 8 | - Validate: processing validation logic on the set of claims; 9 | - Claim generation: generate dynamic value at token creation time; 10 | - Signature creation: encoding header and claims and generate a signature of their value. 11 | 12 | ## Architecture 13 | 14 | The core of Joken is `JOSE`, a library which provides all facilities to sign and verify tokens. 15 | Joken brings an easier Elixir API with some added functionality: 16 | 17 | - Validating claims. JOSE does not provide validation other than signature verification. 18 | - `config.exs` friendly. You can optionally define your signer configuration straight in your 19 | `config.exs`. 20 | - Portable configuration. All your token logic can be encapsulated in a module with behaviours. 21 | - Enhanced errors. Joken strives to be as informative as it can when errors happen be it at 22 | compilation or at validation time. 23 | - Debug friendly. When a token fails validation, a `Logger` debug message will show which claim 24 | failed validation with which value. The return value, though for security reasons, does not 25 | contain these information. 26 | - Performance. We have a benchmark suite for identifying where we can have a better performance. 27 | From this analysis came: Jason adapter for JOSE and other minor tweaks. 28 | 29 | ## Usage 30 | 31 | Joken has 3 basic concepts: 32 | 33 | - Portable token claims configuration. 34 | - Signer configuration. 35 | - Hooks. 36 | 37 | The portable token claims configuration is a map of binary keys to `Joken.Claim` structs and is used 38 | to dynamically generate and validate tokens. 39 | 40 | A signer is an instance of `Joken.Signer` that encapsulates the algorithm and the key configuration 41 | used to sign and verify a token. 42 | 43 | A hook is an implementation of the behaviour `Joken.Hooks` for easy plugging into the lifecycle of 44 | Joken operations. 45 | 46 | There are 2 forms of using Joken: 47 | 48 | 1. Pure data structures. You can create your token configuration and signer and use them with this 49 | module for all 4 operations: verify, validate, generate and sign. 50 | 51 | ``` 52 | iex> token_config = %{} # empty config 53 | iex> token_config = Map.put(token_config, "scope", %Joken.Claim{ 54 | ...> generate: fn -> "user" end, 55 | ...> validate: fn val, _claims, _context -> val in ["user", "admin"] end 56 | ...> }) 57 | iex> signer = Joken.Signer.create("HS256", "my secret") 58 | iex> {:ok, claims} = Joken.generate_claims(token_config, %{"extra"=> "claim"}) 59 | iex> {:ok, jwt, claims} = Joken.encode_and_sign(claims, signer) 60 | ``` 61 | 62 | 2. With the encapsulated module approach using `Joken.Config`. See the docs for `Joken.Config` for 63 | more details. 64 | 65 | ``` 66 | iex> defmodule MyAppToken do 67 | ...> use Joken.Config, default_signer: :pem_rs256 68 | ...> 69 | ...> @impl Joken.Config 70 | ...> def token_config do 71 | ...> default_claims() 72 | ...> |> add_claim("role", fn -> "USER" end, &(&1 in ["ADMIN", "USER"])) 73 | ...> end 74 | ...> end 75 | iex> {:ok, token, _claims} = MyAppToken.generate_and_sign(%{"user_id" => "1234567890"}) 76 | iex> {:ok, _claim_map} = MyAppToken.verify_and_validate(token) 77 | ``` 78 | 79 | """ 80 | alias Joken.{Claim, Hooks, Signer} 81 | require Logger 82 | 83 | @typedoc """ 84 | A signer argument that can be a key in the configuration or an instance of `Joken.Signer`. 85 | """ 86 | @type signer_arg :: atom | Joken.Signer.t() | nil 87 | 88 | @typedoc "A binary representing a bearer token." 89 | @type bearer_token :: binary 90 | 91 | @typedoc "A map with binary keys that represents a claim set." 92 | @type claims :: %{binary => term} 93 | 94 | @typedoc "A list of hooks. Can be either a list of modules or a list of tuples with modules options to pass." 95 | @type hooks :: [module] | [{module, any}] 96 | 97 | @typedoc "A portable configuration of claims for generation and validation." 98 | @type token_config :: %{binary => Joken.Claim.t()} 99 | 100 | @typedoc "Error reason which might contain dynamic data for helping understand the cause." 101 | @type error_reason :: atom | Keyword.t() 102 | 103 | @type generate_result :: {:ok, claims} | {:error, error_reason} 104 | @type sign_result :: {:ok, bearer_token, claims} | {:error, error_reason} 105 | @type verify_result :: {:ok, claims} | {:error, error_reason} 106 | @type validate_result :: {:ok, claims} | {:error, error_reason} 107 | 108 | @doc """ 109 | Retrieves current time in seconds. 110 | 111 | This implementation uses an adapter so that you can replace it on your tests. The adapter is 112 | set through `config.exs`. Example: 113 | 114 | config :joken, 115 | current_time_adapter: Joken.CurrentTime.OS 116 | 117 | See Joken's own tests for an example of how to override this with a customizable time mock. 118 | """ 119 | @spec current_time() :: pos_integer 120 | def current_time, do: current_time_adapter().current_time() 121 | 122 | @doc """ 123 | Decodes the header of a token without validation. 124 | 125 | **Use this with care!** This DOES NOT validate the token signature and therefore the token might 126 | be invalid. The common use case for this function is when you need info to decide on which signer 127 | will be used. Even though there is a use case for this, be extra careful to handle data without 128 | validation. 129 | """ 130 | @spec peek_header(bearer_token) :: {:ok, claims} | {:error, error_reason} 131 | def peek_header(token) when is_binary(token) do 132 | with {:ok, %{"protected" => protected}} <- expand(token), 133 | {:decode64, {:ok, decoded_str}} <- 134 | {:decode64, Base.url_decode64(protected, padding: false)}, 135 | header <- JOSE.json_module().decode(decoded_str) do 136 | {:ok, header} 137 | else 138 | {:decode64, _error} -> {:error, :token_malformed} 139 | error -> error 140 | end 141 | end 142 | 143 | @doc """ 144 | Decodes the claim set of a token without validation. 145 | 146 | **Use this with care!** This DOES NOT validate the token signature and therefore the token might 147 | be invalid. The common use case for this function is when you need info to decide on which signer 148 | will be used. Even though there is a use case for this, be extra careful to handle data without 149 | validation. 150 | """ 151 | @spec peek_claims(bearer_token) :: {:ok, claims} | {:error, error_reason} 152 | def peek_claims(token) when is_binary(token) do 153 | with {:ok, %{"payload" => payload}} <- expand(token), 154 | {:decode64, {:ok, decoded_str}} <- 155 | {:decode64, Base.url_decode64(payload, padding: false)}, 156 | claims <- JOSE.json_module().decode(decoded_str) do 157 | {:ok, claims} 158 | else 159 | {:decode64, _error} -> {:error, :token_malformed} 160 | error -> error 161 | end 162 | end 163 | 164 | @doc """ 165 | Expands a signed token into its 3 parts: protected, payload and signature. 166 | 167 | Protected is also called the JOSE header. It contains metadata only like: 168 | 169 | - `typ`: the token type. 170 | - `kid`: an id for the key used in the signing. 171 | - `alg`: the algorithm used to sign a token. 172 | 173 | Payload is the set of claims and signature is, well, the signature. 174 | """ 175 | def expand(signed_token) do 176 | case String.split(signed_token, ".") do 177 | [header, payload, signature] -> 178 | {:ok, 179 | %{ 180 | "protected" => header, 181 | "payload" => payload, 182 | "signature" => signature 183 | }} 184 | 185 | _ -> 186 | {:error, :token_malformed} 187 | end 188 | end 189 | 190 | @doc """ 191 | Default function for generating `jti` claims. This was inspired by the `Plug.RequestId` generation. 192 | It avoids using `strong_rand_bytes` as it is known to have some contention when running with many 193 | schedulers. 194 | """ 195 | @spec generate_jti() :: binary 196 | def generate_jti do 197 | binary = << 198 | System.system_time(:nanosecond)::64, 199 | :erlang.phash2({node(), self()}, 16_777_216)::24, 200 | :erlang.unique_integer()::32 201 | >> 202 | 203 | Base.hex_encode32(binary, case: :lower) 204 | end 205 | 206 | @doc "Combines `generate_claims/3` with `encode_and_sign/3`" 207 | @spec generate_and_sign(token_config, claims, signer_arg, hooks) :: 208 | {:ok, bearer_token, claims} | {:error, error_reason} 209 | def generate_and_sign( 210 | token_config, 211 | extra_claims \\ %{}, 212 | signer_arg \\ :default_signer, 213 | hooks \\ [] 214 | ) do 215 | case generate_claims(token_config, extra_claims, hooks) do 216 | {:ok, claims} -> encode_and_sign(claims, signer_arg, hooks) 217 | err -> err 218 | end 219 | end 220 | 221 | @doc "Same as `generate_and_sign/4` but raises if result is an error" 222 | @spec generate_and_sign!(token_config, claims, signer_arg, hooks) :: 223 | bearer_token 224 | def generate_and_sign!( 225 | token_config, 226 | extra_claims \\ %{}, 227 | signer_arg \\ :default_signer, 228 | hooks \\ [] 229 | ) do 230 | result = generate_and_sign(token_config, extra_claims, signer_arg, hooks) 231 | 232 | case result do 233 | {:ok, token, _claims} -> 234 | token 235 | 236 | {:error, reason} -> 237 | raise Joken.Error, [:bad_generate_and_sign, reason: reason] 238 | end 239 | end 240 | 241 | @doc """ 242 | Verifies a bearer_token using the given signer and executes hooks if any are given. 243 | """ 244 | @spec verify(bearer_token, signer_arg, hooks) :: verify_result() 245 | def verify(bearer_token, signer, hooks \\ []) 246 | 247 | def verify(bearer_token, nil, hooks) when is_binary(bearer_token) and is_list(hooks), 248 | do: verify(bearer_token, %Signer{}, hooks) 249 | 250 | def verify(bearer_token, signer, hooks) when is_binary(bearer_token) and is_atom(signer), 251 | do: verify(bearer_token, parse_signer(signer), hooks) 252 | 253 | def verify(bearer_token, signer = %Signer{}, hooks) when is_binary(bearer_token) do 254 | with {:ok, {bearer_token, signer}} <- 255 | Hooks.run_before_hook(hooks, :before_verify, {bearer_token, signer}), 256 | :ok <- check_signer_not_empty(signer), 257 | result <- Signer.verify(bearer_token, signer) do 258 | Hooks.run_after_hook(hooks, :after_verify, result, {bearer_token, signer}) 259 | end 260 | end 261 | 262 | defp check_signer_not_empty(%Signer{alg: nil}), do: {:error, :empty_signer} 263 | defp check_signer_not_empty(%Signer{}), do: :ok 264 | 265 | @doc """ 266 | Validates the claim map with the given token configuration and the context. 267 | 268 | Context can by any term. It is always passed as the second argument to the validate 269 | function. It can be, for example, a user struct or anything. 270 | 271 | It also executes hooks if any are given. 272 | """ 273 | @spec validate(token_config, claims, term, hooks) :: validate_result() 274 | def validate(token_config, claims_map, context \\ nil, hooks \\ []) do 275 | with {:ok, {token_config, claims_map, context}} <- 276 | Hooks.run_before_hook(hooks, :before_validate, {token_config, claims_map, context}), 277 | result <- reduce_validations(token_config, claims_map, context), 278 | {:ok, _config, claims, _context} <- 279 | Hooks.run_after_hook( 280 | hooks, 281 | :after_validate, 282 | result, 283 | {token_config, claims_map, context} 284 | ) do 285 | {:ok, claims} 286 | end 287 | end 288 | 289 | @doc "Combines `verify/3` and `validate/4` operations" 290 | @spec verify_and_validate(token_config, bearer_token, signer_arg, term, hooks) :: 291 | {:ok, claims} | {:error, error_reason} 292 | def verify_and_validate( 293 | token_config, 294 | bearer_token, 295 | signer \\ :default_signer, 296 | context \\ nil, 297 | hooks \\ [] 298 | ) do 299 | case verify(bearer_token, signer, hooks) do 300 | {:ok, claims} -> validate(token_config, claims, context, hooks) 301 | err -> err 302 | end 303 | end 304 | 305 | @doc "Same as `verify_and_validate/5` but raises on error" 306 | @spec verify_and_validate!(token_config, bearer_token, signer_arg, term, hooks) :: 307 | claims 308 | def verify_and_validate!( 309 | token_config, 310 | bearer_token, 311 | signer \\ :default_signer, 312 | context \\ nil, 313 | hooks \\ [] 314 | ) do 315 | token_config 316 | |> verify_and_validate(bearer_token, signer, context, hooks) 317 | |> case do 318 | {:ok, claims} -> 319 | claims 320 | 321 | {:error, reason} -> 322 | raise Joken.Error, [:bad_verify_and_validate, reason: reason] 323 | end 324 | end 325 | 326 | @doc """ 327 | Generates claims with the given token configuration and merges them with the given extra claims. 328 | 329 | It also executes hooks if any are given. 330 | """ 331 | @spec generate_claims(token_config, claims | nil, hooks) :: generate_result 332 | def generate_claims(token_config, extra \\ %{}, hooks \\ []) 333 | 334 | def generate_claims(token_config, nil, hooks), do: generate_claims(token_config, %{}, hooks) 335 | 336 | def generate_claims(token_config, extra_claims, hooks) do 337 | with {:ok, {token_config, extra_claims}} <- 338 | Hooks.run_before_hook(hooks, :before_generate, {token_config, extra_claims}), 339 | claims <- Enum.reduce(token_config, extra_claims, &Claim.__generate_claim__/2) do 340 | Hooks.run_after_hook(hooks, :after_generate, {:ok, claims}, {token_config, extra_claims}) 341 | end 342 | end 343 | 344 | @doc """ 345 | Encodes and generates a token from the given claim map and signs the result with the given signer. 346 | 347 | It also executes hooks if any are given. 348 | """ 349 | @spec encode_and_sign(claims, signer_arg, hooks) :: sign_result 350 | def encode_and_sign(claims, signer, hooks \\ []) 351 | 352 | def encode_and_sign(claims, nil, hooks), 353 | do: encode_and_sign(claims, %Signer{}, hooks) 354 | 355 | def encode_and_sign(claims, signer, hooks) when is_atom(signer), 356 | do: encode_and_sign(claims, parse_signer(signer), hooks) 357 | 358 | def encode_and_sign(claims, %Signer{} = signer, hooks) do 359 | with {:ok, {claims, signer}} <- Hooks.run_before_hook(hooks, :before_sign, {claims, signer}), 360 | :ok <- check_signer_not_empty(signer), 361 | result <- Signer.sign(claims, signer), 362 | {:ok, token} <- Hooks.run_after_hook(hooks, :after_sign, result, {claims, signer}) do 363 | {:ok, token, claims} 364 | end 365 | end 366 | 367 | defp parse_signer(signer_key) do 368 | Signer.parse_config(signer_key) || raise(Joken.Error, :no_default_signer) 369 | end 370 | 371 | defp reduce_validations(_config, %{} = claims, _context) when map_size(claims) == 0, 372 | do: {:ok, claims} 373 | 374 | defp reduce_validations(config, claim_map, context) do 375 | claim_map 376 | |> Enum.reduce_while(nil, fn {key, claim_val}, _acc -> 377 | # When there is a function for validating the token 378 | with %Claim{validate: val_func} when not is_nil(val_func) <- config[key], 379 | true <- val_func.(claim_val, claim_map, context) do 380 | {:cont, :ok} 381 | else 382 | # When there is no configuration for the claim 383 | nil -> 384 | {:cont, :ok} 385 | 386 | # When there is a configuration but no validation function 387 | %Claim{validate: nil} -> 388 | {:cont, :ok} 389 | 390 | # When it fails validation 391 | false -> 392 | Logger.debug(fn -> 393 | """ 394 | Claim %{"#{key}" => #{inspect(claim_val)}} did not pass validation. 395 | 396 | Current time: #{inspect(Joken.current_time())} 397 | """ 398 | end) 399 | 400 | message = Keyword.get(config[key].options, :message, "Invalid token") 401 | {:halt, {:error, message: message, claim: key, claim_val: claim_val}} 402 | end 403 | end) 404 | |> case do 405 | :ok -> {:ok, claim_map} 406 | err -> err 407 | end 408 | end 409 | 410 | # This ensures we provide an easy to setup test environment 411 | defp current_time_adapter, 412 | do: Application.get_env(:joken, :current_time_adapter, Joken.CurrentTime.OS) 413 | end 414 | -------------------------------------------------------------------------------- /lib/joken/claim.ex: -------------------------------------------------------------------------------- 1 | defmodule Joken.Claim do 2 | @moduledoc """ 3 | Structure for a dynamic claim. It is used for holding functions that generate 4 | and validate claims. 5 | """ 6 | 7 | @type t :: %__MODULE__{ 8 | generate: fun() | nil, 9 | validate: fun() | nil, 10 | options: list() 11 | } 12 | 13 | # We have options here for customizing error messages and other possible extras 14 | defstruct generate: nil, 15 | validate: nil, 16 | options: [] 17 | 18 | @doc false 19 | def __generate_claim__({key, %__MODULE__{generate: gen_fun}}, acc) 20 | when is_binary(key) and is_map(acc) do 21 | case Map.has_key?(acc, key) or not is_function(gen_fun, 0) do 22 | true -> 23 | acc 24 | 25 | _ -> 26 | Map.put(acc, key, gen_fun.()) 27 | end 28 | end 29 | 30 | def __generate_claim__(_, acc), do: acc 31 | end 32 | -------------------------------------------------------------------------------- /lib/joken/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Joken.Config do 2 | @moduledoc ~S""" 3 | Main entry point for configuring Joken. This module has two approaches: 4 | 5 | ## Creating a map of `Joken.Claim` s 6 | 7 | If you prefer to avoid using macros, you can create your configuration manually. Joken's 8 | configuration is just a map with keys being binaries (the claim name) and the value an 9 | instance of `Joken.Claim`. 10 | 11 | ### Example 12 | 13 | %{"exp" => %Joken.Claim{ 14 | generate: fn -> Joken.Config.current_time() + (2 * 60 * 60) end, 15 | validate: fn val, _claims, _context -> val < Joken.Config.current_time() end 16 | }} 17 | 18 | Since this is cumbersome and error prone, you can use this module with a more fluent API, see: 19 | 20 | - `default_claims/1` 21 | - `add_claim/4` 22 | 23 | ## Automatically load and generate functions (recommended) 24 | 25 | Another approach is to just `use Joken.Config` in a module. This will load a signer configuration 26 | (from config.exs) and a map of `Joken.Claim` s. 27 | 28 | ### Example 29 | 30 | defmodule MyAuth do 31 | use Joken.Config 32 | end 33 | 34 | This way, `Joken.Config` will implement some functions for you: 35 | 36 | - `generate_claims/1`: generates dynamic claims and adds them to the passed map. 37 | - `encode_and_sign/2`: takes a map of claims, encodes it to JSON and signs it. 38 | - `verify/2`: check for token tampering using a signer. 39 | - `validate/2`: takes a claim map and a configuration to run validations. 40 | - `generate_and_sign/2`: combines generation and signing. 41 | - `verify_and_validate/2`: combines verification and validation. 42 | - `token_config/0`: where you customize token generation and validation. 43 | 44 | It will also add `use Joken.Hooks` so you can easily hook into Joken's lifecycle. 45 | 46 | ## Overriding functions 47 | 48 | All callbacks in `Joken.Config` and `Joken.Hooks` are overridable. This can be used for 49 | customizing the token configuration. All that is needed is to override the `token_config/0` 50 | function returning your map of binary keys to `Joken.Claim` structs. Example from the 51 | benchmark suite: 52 | 53 | defmodule MyCustomClaimsAuth do 54 | use Joken.Config 55 | 56 | @impl true 57 | def token_config do 58 | %{} # empty claim map 59 | |> add_claim("name", fn -> "John Doe" end, &(&1 == "John Doe")) 60 | |> add_claim("test", fn -> true end, &(&1 == true)) 61 | |> add_claim("age", fn -> 666 end, &(&1 > 18)) 62 | |> add_claim("simple time test", fn -> 1 end, &(Joken.current_time() > &1)) 63 | end 64 | end 65 | 66 | ## Customizing default generated claims 67 | 68 | The default claims generation is just a bypass call to `default_claims/1`. If one would 69 | like to customize it, then we need only to override the token_config function: 70 | 71 | defmodule MyCustomDefaults do 72 | use Joken.Config 73 | 74 | def token_config, do: default_claims(default_exp: 60 * 60) # 1 hour 75 | end 76 | 77 | ### Options 78 | 79 | You can pass some options to `use Joken.Config` to ease on your configuration: 80 | 81 | - `:default_signer`: a signer configuration key in config.exs (see `Joken.Signer`) 82 | """ 83 | import Joken, only: [current_time: 0] 84 | alias Joken.Signer 85 | 86 | @default_generated_claims [:exp, :iat, :nbf, :iss, :aud, :jti] 87 | 88 | @doc """ 89 | Defines the `t:Joken.token_config/0` used for all the operations in this module. 90 | 91 | The default implementation is just a bypass call to `default_claims/1`. 92 | """ 93 | @callback token_config() :: Joken.token_config() 94 | 95 | @doc """ 96 | Generates a JWT claim set. 97 | 98 | Extra claims must be a map with keys as binaries. Ex: %{"sub" => "some@one.com"} 99 | """ 100 | @callback generate_claims(extra :: Joken.claims()) :: 101 | {:ok, Joken.claims()} | {:error, Joken.error_reason()} 102 | 103 | @doc """ 104 | Encodes the given map of claims to JSON and signs it. 105 | 106 | The signer used will be (in order of preference): 107 | 108 | 1. The one represented by the key passed as second argument. The signer will be 109 | parsed from the configuration. 110 | 2. If no argument was passed then we will use the one from the configuration 111 | `:default_signer` passed as argument for the `use Joken.Config` macro. 112 | 3. If no key was passed for the use macro then we will use the one configured as 113 | `:default_signer` in the configuration. 114 | """ 115 | @callback encode_and_sign(Joken.claims(), Joken.signer_arg() | nil) :: 116 | {:ok, Joken.bearer_token(), Joken.claims()} | {:error, Joken.error_reason()} 117 | 118 | @doc """ 119 | Verifies token's signature using a Joken.Signer. 120 | 121 | The signer used is (in order of precedence): 122 | 123 | 1. The signer in the configuration with the given `key`. 124 | 2. The `Joken.Signer` instance passed to the method. 125 | 3. The signer passed in the `use Joken.Config` through the `default_signer` key. 126 | 4. The default signer in configuration (the one with the key `default_signer`). 127 | 128 | It returns either: 129 | 130 | - `{:ok, claims_map}` where claims_map is the token's claims. 131 | - `{:error, [message: message, claim: key, claim_val: claim_value]}` where message can be used 132 | on the frontend (it does not contain which claim nor which value failed). 133 | """ 134 | @callback verify(Joken.bearer_token(), Joken.signer_arg() | nil) :: 135 | {:ok, Joken.claims()} | {:error, Joken.error_reason()} 136 | 137 | @doc """ 138 | Runs validations on the already verified token. 139 | """ 140 | @callback validate(Joken.claims(), term) :: 141 | {:ok, Joken.claims()} | {:error, Joken.error_reason()} 142 | 143 | defmacro __using__(options) do 144 | quote do 145 | import Joken, only: [current_time: 0] 146 | import Joken.Config 147 | use Joken.Hooks 148 | 149 | @behaviour Joken.Config 150 | 151 | @hooks [__MODULE__] 152 | 153 | @before_compile Joken.Config 154 | 155 | @doc false 156 | def __default_signer__ do 157 | key = unquote(options)[:default_signer] || :default_signer 158 | Signer.parse_config(key) 159 | end 160 | 161 | @impl Joken.Config 162 | def token_config, do: default_claims() 163 | 164 | @impl Joken.Config 165 | def generate_claims(extra_claims \\ %{}), 166 | do: Joken.generate_claims(token_config(), extra_claims, __hooks__()) 167 | 168 | @impl Joken.Config 169 | def encode_and_sign(claims, signer \\ nil) 170 | 171 | def encode_and_sign(claims, nil), 172 | do: Joken.encode_and_sign(claims, __default_signer__(), __hooks__()) 173 | 174 | def encode_and_sign(claims, signer), 175 | do: Joken.encode_and_sign(claims, signer, __hooks__()) 176 | 177 | @impl Joken.Config 178 | def verify(bearer_token, key \\ nil) 179 | 180 | def verify(bearer_token, nil), 181 | do: Joken.verify(bearer_token, __default_signer__(), __hooks__()) 182 | 183 | def verify(bearer_token, signer), 184 | do: Joken.verify(bearer_token, signer, __hooks__()) 185 | 186 | @impl Joken.Config 187 | def validate(claims, context \\ %{}), 188 | do: Joken.validate(token_config(), claims, context, __hooks__()) 189 | 190 | defoverridable token_config: 0, 191 | generate_claims: 1, 192 | encode_and_sign: 2, 193 | verify: 2, 194 | validate: 2 195 | 196 | @doc "Combines `generate_claims/1` and `encode_and_sign/2`" 197 | @spec generate_and_sign(Joken.claims(), Joken.signer_arg()) :: 198 | {:ok, Joken.bearer_token(), Joken.claims()} | {:error, Joken.error_reason()} 199 | def generate_and_sign(extra_claims \\ %{}, key \\ __default_signer__()), 200 | do: Joken.generate_and_sign(token_config(), extra_claims, key, __hooks__()) 201 | 202 | @doc "Same as `generate_and_sign/2` but raises if error" 203 | @spec generate_and_sign!(Joken.claims(), Joken.signer_arg()) :: 204 | Joken.bearer_token() 205 | def generate_and_sign!(extra_claims \\ %{}, key \\ __default_signer__()), 206 | do: Joken.generate_and_sign!(token_config(), extra_claims, key, __hooks__()) 207 | 208 | @doc "Combines `verify/2` and `validate/2`" 209 | @spec verify_and_validate(Joken.bearer_token(), Joken.signer_arg(), term) :: 210 | {:ok, Joken.claims()} | {:error, Joken.error_reason()} 211 | def verify_and_validate(bearer_token, key \\ __default_signer__(), context \\ %{}), 212 | do: Joken.verify_and_validate(token_config(), bearer_token, key, context, __hooks__()) 213 | 214 | @doc "Same as `verify_and_validate/2` but raises if error" 215 | @spec verify_and_validate!(Joken.bearer_token(), Joken.signer_arg(), term) :: 216 | Joken.claims() 217 | def verify_and_validate!(bearer_token, key \\ __default_signer__(), context \\ %{}), 218 | do: Joken.verify_and_validate!(token_config(), bearer_token, key, context, __hooks__()) 219 | end 220 | end 221 | 222 | defmacro __before_compile__(_env) do 223 | quote do 224 | def __hooks__, do: @hooks 225 | end 226 | end 227 | 228 | @doc """ 229 | Adds the given hook to the list of hooks passed to all operations in this module. 230 | 231 | When using `use Joken.Config` in a module, this already adds the module as a hook. 232 | So, if you want to only override one lifecycle callback, you can simply override it 233 | on the module that uses `Joken.Config`. 234 | """ 235 | defmacro add_hook(hook_module, options \\ []) do 236 | quote do 237 | @hooks [unquote({hook_module, options}) | @hooks] 238 | end 239 | end 240 | 241 | @doc """ 242 | Initializes a map of `Joken.Claim`s with "exp", "iat", "nbf", "iss", "aud" and "jti". 243 | 244 | Default parameters can be customized with options: 245 | 246 | - `:skip`: do not include claims in this list. Ex: [:iss, :aud] 247 | - `:default_exp`: changes the default expiration of the token. Default is 2 hours 248 | - `:iss`: changes the issuer claim. Default is "Joken" 249 | - `:aud`: changes the audience claim. Default is "Joken" 250 | """ 251 | @spec default_claims(Keyword.t()) :: Joken.token_config() 252 | # credo:disable-for-next-line 253 | def default_claims(options \\ []) do 254 | skip = options[:skip] || [] 255 | default_exp = options[:default_exp] || 2 * 60 * 60 256 | default_iss = options[:iss] || "Joken" 257 | default_aud = options[:aud] || "Joken" 258 | generate_jti = options[:generate_jti] || (&Joken.generate_jti/0) 259 | 260 | unless is_integer(default_exp) and is_binary(default_iss) and is_binary(default_aud) and 261 | is_function(generate_jti) and is_list(skip) do 262 | raise Joken.Error, :invalid_default_claims 263 | end 264 | 265 | generate_config(skip, default_exp, default_iss, default_aud, generate_jti) 266 | end 267 | 268 | defp generate_config(skip, default_exp, default_iss, default_aud, generate_jti) do 269 | Enum.reduce(@default_generated_claims, %{}, fn claim, acc -> 270 | cond do 271 | claim in skip -> 272 | acc 273 | 274 | # credo:disable-for-lines:14 Credo.Check.Refactor.Nesting 275 | claim == :exp -> 276 | add_claim(acc, "exp", fn -> current_time() + default_exp end, &(&1 > current_time())) 277 | 278 | claim == :iat -> 279 | add_claim(acc, "iat", fn -> current_time() end) 280 | 281 | claim == :nbf -> 282 | add_claim(acc, "nbf", fn -> current_time() end, &(current_time() >= &1)) 283 | 284 | claim == :iss -> 285 | add_claim(acc, "iss", fn -> default_iss end, &(&1 == default_iss)) 286 | 287 | claim == :aud -> 288 | add_claim(acc, "aud", fn -> default_aud end, &(&1 == default_aud)) 289 | 290 | claim == :jti -> 291 | add_claim(acc, "jti", generate_jti) 292 | end 293 | end) 294 | end 295 | 296 | @doc """ 297 | Adds a `Joken.Claim` with the given claim key to a map. 298 | 299 | This is a convenience builder function. It does exactly what this example does: 300 | 301 | iex> config = %{} 302 | iex> generate_fun = fn -> "Hi" end 303 | iex> validate_fun = &(&1 =~ "Hi") 304 | iex> claim = %Joken.Claims{generate: generate_fun, validate: validate_fun} 305 | iex> config = Map.put(config, "claim key", claim) 306 | 307 | """ 308 | @spec add_claim(Joken.token_config(), binary, fun | nil, fun | nil, Keyword.t()) :: 309 | Joken.token_config() 310 | def add_claim(config, claim_key, generate_fun \\ nil, validate_fun \\ nil, options \\ []) 311 | 312 | def add_claim(config, claim_key, nil, nil, _options) 313 | when is_map(config) and is_binary(claim_key) do 314 | raise Joken.Error, :claim_configuration_not_valid 315 | end 316 | 317 | def add_claim(config, claim_key, generate_fun, validate_fun, options) 318 | when is_map(config) and is_binary(claim_key) do 319 | validate_fun = if validate_fun, do: wrap_validate_fun(validate_fun), else: validate_fun 320 | 321 | claim = %Joken.Claim{generate: generate_fun, validate: validate_fun, options: options} 322 | Map.put(config, claim_key, claim) 323 | end 324 | 325 | # This ensures that all validate functions are called with arity 2 and gives some 326 | # more helpful message in case of errors 327 | defp wrap_validate_fun(fun) do 328 | {:arity, arity} = :erlang.fun_info(fun, :arity) 329 | 330 | case arity do 331 | 1 -> 332 | fn val, _claims, _ctx -> fun.(val) end 333 | 334 | 2 -> 335 | fn val, claims, _ctx -> fun.(val, claims) end 336 | 337 | 3 -> 338 | fun 339 | 340 | _ -> 341 | raise Joken.Error, :bad_validate_fun_arity 342 | end 343 | end 344 | end 345 | -------------------------------------------------------------------------------- /lib/joken/current_time.ex: -------------------------------------------------------------------------------- 1 | defmodule Joken.CurrentTime do 2 | @moduledoc "Behaviour for fetching current time." 3 | 4 | @doc """ 5 | Returns the current time in seconds. 6 | 7 | This is used for applications that want to control time for testing. 8 | """ 9 | @callback current_time() :: pos_integer 10 | end 11 | 12 | defmodule Joken.CurrentTime.OS do 13 | @moduledoc """ 14 | Time source for default time based claims. Can be overridden in tests. 15 | """ 16 | 17 | @behaviour Joken.CurrentTime 18 | 19 | @doc """ 20 | Returns current time in seconds. 21 | 22 | Uses DateTime.utc_now/0. 23 | """ 24 | @spec current_time() :: pos_integer 25 | def current_time, do: DateTime.utc_now() |> DateTime.to_unix() 26 | end 27 | -------------------------------------------------------------------------------- /lib/joken/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Joken.Error do 2 | @moduledoc """ 3 | Errors for the Joken API. 4 | """ 5 | defexception [:reason] 6 | 7 | alias Joken.Signer 8 | 9 | @doc false 10 | def exception(reason), do: %__MODULE__{reason: reason} 11 | 12 | def message(%__MODULE__{reason: :no_default_signer}), 13 | do: """ 14 | Can't sign your token because couldn't create a signer. 15 | 16 | To create a signer we need a key in config.exs. You can define 17 | a key in your config.exs in several ways: 18 | 19 | 1. For the default key, use `config :joken, default_signer: ` 20 | 2. For other keys, use `config :joken, : ` 21 | 22 | If you are using different than default keys, you can pass it as the second 23 | argument to `generate_and_sign/2` or as a parameter for `use Joken.Config`, 24 | example: `use Joken.Config, default_signer: ` 25 | 26 | See configuration docs for possible values of . 27 | """ 28 | 29 | def message(%__MODULE__{reason: [:bad_generate_and_sign, reason: result]}), 30 | do: """ 31 | Error while calling `generate_and_sign!`. Reason: #{inspect(result)}. 32 | """ 33 | 34 | def message(%__MODULE__{reason: [:bad_verify_and_validate, reason: result]}), 35 | do: """ 36 | Error while calling `verify_and_validate!`. Reason: #{inspect(result)}. 37 | """ 38 | 39 | def message(%__MODULE__{reason: :invalid_default_claims}), 40 | do: """ 41 | Invalid argument to default claims. Verify the types of the arguments to 42 | Joken.Config.default_claims/1. 43 | """ 44 | 45 | def message(%__MODULE__{reason: :algorithm_needs_key}), 46 | do: """ 47 | A map was expected for the key parameter in the signer creation. 48 | This is mandatory for: #{inspect(Signer.map_key_algorithms())}. 49 | """ 50 | 51 | def message(%__MODULE__{reason: :unrecognized_algorithm}), 52 | do: """ 53 | Couldn't recognize the signer algorithm. 54 | 55 | Possible values are: 56 | 57 | #{inspect(Signer.algorithms())} 58 | """ 59 | 60 | def message(%__MODULE__{reason: :claim_not_valid}), 61 | do: """ 62 | Claim did not pass validation. 63 | 64 | Set log level to debug for more information. 65 | """ 66 | 67 | def message(%__MODULE__{reason: :claim_configuration_not_valid}), 68 | do: """ 69 | Claim configuration is not valid. You must have either a generation function or a 70 | validation function. 71 | 72 | If both are nil you don`t need a Joken.Claim configuration. You can pass any map of values 73 | to `Joken.Config.generate_and_sign/3`. Verify will only use claims that have a validation 74 | function on your configuration. Example: 75 | 76 | defmodule CustomClaimTest do 77 | use Joken.Config 78 | end 79 | 80 | CustomClaimTest.generate_and_sign %{"a claim without configuration" => "any value"} 81 | """ 82 | 83 | def message(%__MODULE__{reason: :bad_validate_fun_arity}), 84 | do: """ 85 | Claim validate function must have either arity 1 or 2. 86 | 87 | When arity is 1, it receives the claim value in a given JWT. 88 | 89 | When it is 2, besides the claim value, it receives a context map. You can pass dynamic 90 | values on this context and pass it to the validate function. 91 | 92 | See `Joken.Config.validate/2` for more information on Context 93 | """ 94 | 95 | def message(%__MODULE__{reason: :algorithm_needs_binary_key}), 96 | do: """ 97 | Couldn't create a signer because key is not binary. 98 | 99 | HMAC SHA algorithms need a binary key. 100 | """ 101 | 102 | def message(%__MODULE__{reason: :wrong_key_parameters}), 103 | do: """ 104 | Couldn't create a signer because there are missing parameters. 105 | 106 | Check the Joken.Signer.parse_config/2 documentation for the types of parameters needed 107 | for each type of algorithm. 108 | """ 109 | end 110 | -------------------------------------------------------------------------------- /lib/joken/hooks.ex: -------------------------------------------------------------------------------- 1 | defmodule Joken.Hooks do 2 | @moduledoc """ 3 | Behaviour for defining hooks into Joken's lifecycle. 4 | 5 | Hooks are passed to `Joken` functions or added to `Joken.Config` through the 6 | `Joken.Config.add_hook/2` macro. They can change the execution flow of a token configuration. 7 | 8 | There are 2 kinds of hooks: before and after. 9 | 10 | Both of them are executed in a reduce_while call and so must always return either: 11 | - `{:halt, ...}` -> when you want to abort execution (other hooks won't be called) 12 | - `{:cont, ...}` -> when you want to let other hooks execute 13 | 14 | ## Before hooks 15 | 16 | A before hook receives as the first parameter its options and then a tuple with the input of 17 | the function. For example, the `generate_claims` function receives the token configuration plus a 18 | map of extra claims. Therefore, a `before_generate` hook receives: 19 | - the hook options or `[]` if none are given; 20 | - a tuple with two elements where the first is the token configuration and the second is the extra 21 | claims map; 22 | 23 | The return of a before hook is always the input of the next hook. Say you want to add an extra claim 24 | with a hook. You could do so like in this example: 25 | 26 | defmodule EnsureExtraClaimHook do 27 | use Joken.Hooks 28 | 29 | @impl true 30 | def before_generate(_hook_options, {token_config, extra_claims}) do 31 | {:cont, {token_config, Map.put(extra_claims, "must_exist", true)}} 32 | end 33 | end 34 | 35 | You could also halt execution completely on a before hook. Just use the `:halt` return with an error 36 | tuple: 37 | 38 | defmodule StopTheWorldHook do 39 | use Joken.Hooks 40 | 41 | @impl true 42 | def before_generate(_hook_options, _input) do 43 | {:halt, {:error, :stop_the_world}} 44 | end 45 | end 46 | 47 | ## After hooks 48 | 49 | After hooks work similar then before hooks. The difference is that it takes and returns the result of the 50 | operation. So, instead of receiving 2 arguments it takes three: 51 | - the hook options or `[]` if none are given; 52 | - the result tuple which might be `{:error, reason}` or a tuple with `:ok` and its parameters; 53 | - the input to the function call. 54 | 55 | Let's see an example with `after_verify`. The verify function takes as argument the token and a signer. So, 56 | an `after_verify` might look like this: 57 | 58 | defmodule CheckVerifyError do 59 | use Joken.Hooks 60 | require Logger 61 | 62 | @impl true 63 | def after_verify(_hook_options, result, input) do 64 | case result do 65 | {:error, :invalid_signature} -> 66 | Logger.error("Check signer!!!") 67 | {:halt, result} 68 | 69 | {:ok, _claims} -> 70 | {:cont, result, input} 71 | end 72 | end 73 | end 74 | 75 | On this example we have conditional logic for different results. 76 | 77 | ## `Joken.Config` 78 | 79 | When you create a module that has `use Joken.Config` it automatically implements 80 | this behaviour with overridable functions. You can simply override a callback 81 | implementation directly and it will be triggered when using any of the generated 82 | functions. Example: 83 | 84 | defmodule HookToken do 85 | use Joken.Config 86 | 87 | @impl Joken.Hooks 88 | def before_generate(_options, input) do 89 | IO.puts("Before generating claims") 90 | {:cont, input} 91 | end 92 | end 93 | 94 | Now if we call `HookToken.generate_claims/1` it will call our callback. 95 | 96 | Also in `Joken.Config` there is an imported macro for adding hooks with options. Example: 97 | 98 | defmodule ManyHooks do 99 | use Joken.Config 100 | 101 | add_hook(JokenJwks, jwks_url: "http://someserver.com/.well-known/certs") 102 | end 103 | 104 | For an implementation reference, please see the source code of `Joken.Hooks.RequiredClaims` 105 | """ 106 | alias Joken.Signer 107 | 108 | @type halt_tuple :: {:halt, tuple} 109 | @type hook_options :: Keyword.t() 110 | @type generate_input :: {Joken.token_config(), extra :: Joken.claims()} 111 | @type sign_input :: {Joken.claims(), Signer.t()} 112 | @type verify_input :: {Joken.bearer_token(), Signer.t()} 113 | @type validate_input :: {Joken.token_config(), Joken.claims(), context :: map()} 114 | 115 | @doc "Called before `Joken.generate_claims/3`" 116 | @callback before_generate(hook_options, generate_input) :: {:cont, generate_input} | halt_tuple 117 | 118 | @doc "Called before `Joken.encode_and_sign/3`" 119 | @callback before_sign(hook_options, sign_input) :: {:cont, sign_input} | halt_tuple 120 | 121 | @doc "Called before `Joken.verify/3`" 122 | @callback before_verify(hook_options, verify_input) :: {:cont, verify_input} | halt_tuple 123 | 124 | @doc "Called before `Joken.validate/4`" 125 | @callback before_validate(hook_options, validate_input) :: {:cont, validate_input} | halt_tuple 126 | 127 | @doc "Called after `Joken.generate_claims/3`" 128 | @callback after_generate(hook_options, Joken.generate_result(), generate_input) :: 129 | {:cont, Joken.generate_result(), generate_input} | halt_tuple 130 | 131 | @doc "Called after `Joken.encode_and_sign/3`" 132 | @callback after_sign( 133 | hook_options, 134 | {:ok, Joken.bearer_token()} | {:error, Joken.error_reason()}, 135 | sign_input 136 | ) :: {:cont, Joken.sign_result(), sign_input} | halt_tuple 137 | 138 | @doc "Called after `Joken.verify/3`" 139 | @callback after_verify( 140 | hook_options, 141 | Joken.verify_result(), 142 | verify_input 143 | ) :: {:cont, Joken.verify_result(), verify_input} | halt_tuple 144 | 145 | @doc "Called after `Joken.validate/4`" 146 | @callback after_validate( 147 | hook_options, 148 | Joken.validate_result(), 149 | validate_input 150 | ) :: {:cont, Joken.validate_result(), validate_input} | halt_tuple 151 | 152 | defmacro __using__(_opts) do 153 | quote do 154 | @behaviour Joken.Hooks 155 | 156 | @impl true 157 | def before_generate(_hook_options, input), do: {:cont, input} 158 | 159 | @impl true 160 | def before_sign(_hook_options, input), do: {:cont, input} 161 | 162 | @impl true 163 | def before_verify(_hook_options, input), do: {:cont, input} 164 | 165 | @impl true 166 | def before_validate(_hook_options, input), do: {:cont, input} 167 | 168 | @impl true 169 | def after_generate(_hook_options, result, input), do: {:cont, result, input} 170 | 171 | @impl true 172 | def after_sign(_hook_options, result, input), do: {:cont, result, input} 173 | 174 | @impl true 175 | def after_verify(_hook_options, result, input), do: {:cont, result, input} 176 | 177 | @impl true 178 | def after_validate(_hook_options, result, input), do: {:cont, result, input} 179 | 180 | defoverridable before_generate: 2, 181 | before_sign: 2, 182 | before_verify: 2, 183 | before_validate: 2, 184 | after_generate: 3, 185 | after_sign: 3, 186 | after_verify: 3, 187 | after_validate: 3 188 | end 189 | end 190 | 191 | @before_hooks [:before_generate, :before_sign, :before_verify, :before_validate] 192 | @after_hooks [:after_generate, :after_sign, :after_verify, :after_validate] 193 | 194 | def run_before_hook(hooks, hook_function, input) when hook_function in @before_hooks do 195 | hooks 196 | |> Enum.reduce_while(input, fn hook, input -> 197 | {hook, opts} = unwrap_hook(hook) 198 | 199 | case apply(hook, hook_function, [opts, input]) do 200 | {:cont, _next_input} = res -> res 201 | {:halt, _reason} = res -> res 202 | _ -> {:halt, {:error, :wrong_hook_return}} 203 | end 204 | end) 205 | |> case do 206 | {:error, _reason} = err -> err 207 | res -> {:ok, res} 208 | end 209 | end 210 | 211 | def run_after_hook(hooks, hook_function, result, input) when hook_function in @after_hooks do 212 | hooks 213 | |> Enum.reduce_while({result, input}, fn hook, {result, input} -> 214 | {hook, opts} = unwrap_hook(hook) 215 | 216 | case apply(hook, hook_function, [opts, result, input]) do 217 | {:cont, result, next_input} -> {:cont, {result, next_input}} 218 | {:halt, _reason} = res -> res 219 | _ -> {:halt, {:error, :wrong_hook_return}} 220 | end 221 | end) 222 | |> case do 223 | {result, input} when is_tuple(input) -> result 224 | res -> res 225 | end 226 | end 227 | 228 | defp unwrap_hook({_hook_module, _opts} = hook), do: hook 229 | defp unwrap_hook(hook) when is_atom(hook), do: {hook, []} 230 | end 231 | -------------------------------------------------------------------------------- /lib/joken/hooks/required_claims.ex: -------------------------------------------------------------------------------- 1 | defmodule Joken.Hooks.RequiredClaims do 2 | @moduledoc """ 3 | Hook to demand claims presence. 4 | 5 | Adding this hook to your token configuration will allow to ensure some claims are present. It 6 | adds an `after_validate/3` implementation that checks claims presence. 7 | 8 | ## Example 9 | 10 | defmodule MyToken do 11 | use Joken.Config 12 | 13 | add_hook Joken.Hooks.RequiredClaims, [:claim1, :claim2] 14 | end 15 | 16 | On missing claims it returns: `{:error, [message: "Invalid token", missing_claims: claims]}`. 17 | """ 18 | use Joken.Hooks 19 | 20 | @impl Joken.Hooks 21 | def after_validate([], _, _) do 22 | raise "Missing required claims options" 23 | end 24 | 25 | def after_validate(opts, _, _) when not is_list(opts) do 26 | raise "Options must be a list of claim keys" 27 | end 28 | 29 | def after_validate(required_claims, {:ok, claims} = result, input) do 30 | required_claims = required_claims |> Enum.map(&map_keys/1) |> MapSet.new() 31 | claims = claims |> Map.keys() |> MapSet.new() 32 | 33 | required_claims 34 | |> MapSet.subset?(claims) 35 | |> case do 36 | true -> 37 | {:cont, result, input} 38 | 39 | _ -> 40 | diff = required_claims |> MapSet.difference(claims) |> MapSet.to_list() 41 | {:halt, {:error, [message: "Invalid token", missing_claims: diff]}} 42 | end 43 | end 44 | 45 | def after_validate(_, result, input), do: {:cont, result, input} 46 | 47 | # will raise if not binary or atom 48 | defp map_keys(key) when is_binary(key), do: key 49 | defp map_keys(key) when is_atom(key), do: Atom.to_string(key) 50 | end 51 | -------------------------------------------------------------------------------- /lib/joken/signer.ex: -------------------------------------------------------------------------------- 1 | defmodule Joken.Signer do 2 | @moduledoc """ 3 | Interface between Joken and JOSE for signing and verifying tokens. 4 | 5 | In the future we plan to keep this interface but make it pluggable for other crypto 6 | implementations like using only standard `:crypto` and `:public_key` modules. So, 7 | **avoid** depending on the inner structure of this module. 8 | """ 9 | alias JOSE.{JWK, JWS, JWT} 10 | 11 | @hs_algorithms ["HS256", "HS384", "HS512"] 12 | @rs_algorithms ["RS256", "RS384", "RS512"] 13 | @es_algorithms ["ES256", "ES384", "ES512"] 14 | @ps_algorithms ["PS256", "PS384", "PS512"] 15 | @eddsa_algorithms ["Ed25519", "Ed25519ph", "Ed448", "Ed448ph", "EdDSA"] 16 | 17 | @map_key_algorithms @rs_algorithms ++ @es_algorithms ++ @ps_algorithms ++ @eddsa_algorithms 18 | 19 | @algorithms @hs_algorithms ++ @map_key_algorithms 20 | 21 | @typedoc "A key may be an octet or a map with parameters according to JWK (JSON Web Key)" 22 | @type key :: binary() | map() 23 | 24 | @typedoc """ 25 | A `Joken.Signer` instance is a JWS (JSON Web Signature) and JWK (JSON Web Key) struct. 26 | 27 | It also contains an `alg` field for performance reasons. 28 | """ 29 | @type t :: %__MODULE__{ 30 | jwk: JWK.t() | nil, 31 | jws: JWS.t() | nil, 32 | alg: binary() | nil 33 | } 34 | 35 | defstruct jwk: nil, jws: nil, alg: nil 36 | 37 | @doc """ 38 | All supported algorithms. 39 | """ 40 | @spec algorithms() :: [binary()] 41 | def algorithms, do: @algorithms 42 | 43 | @doc """ 44 | Map key algorithms. 45 | """ 46 | @spec map_key_algorithms() :: [binary()] 47 | def map_key_algorithms, do: @map_key_algorithms 48 | 49 | @doc """ 50 | Creates a new Joken.Signer struct. Can accept either a binary for HS*** algorithms 51 | or a map with arguments for the other kinds of keys. Also, accepts an optional map 52 | that will be passed as extra header arguments for generated JWT tokens. 53 | 54 | ## Example 55 | 56 | iex> Joken.Signer.create("HS256", "s3cret") 57 | %Joken.Signer{ 58 | alg: "HS256", 59 | jwk: %JOSE.JWK{ 60 | fields: %{}, 61 | keys: :undefined, 62 | kty: {:jose_jwk_kty_oct, "s3cret"} 63 | }, 64 | jws: %JOSE.JWS{ 65 | alg: {:jose_jws_alg_hmac, :HS256}, 66 | b64: :undefined, 67 | fields: %{"typ" => "JWT"} 68 | } 69 | } 70 | 71 | """ 72 | @spec create(binary(), key(), %{binary() => term()}) :: __MODULE__.t() 73 | def create(alg, key, jose_extra_headers \\ %{}) 74 | 75 | def create(alg, secret, headers) when is_binary(secret) and alg in @hs_algorithms do 76 | raw_create( 77 | alg, 78 | headers |> transform_headers(alg) |> JWS.from_map(), 79 | JWK.from_oct(secret) 80 | ) 81 | end 82 | 83 | def create(alg, _key, _headers) when alg in @hs_algorithms, 84 | do: raise(Joken.Error, :algorithm_needs_binary_key) 85 | 86 | def create(alg, %{"pem" => pem, "passphrase" => passphrase}, headers) 87 | when alg in @map_key_algorithms do 88 | raw_create( 89 | alg, 90 | headers |> transform_headers(alg) |> JWS.from_map(), 91 | JWK.from_pem(passphrase, pem) 92 | ) 93 | end 94 | 95 | def create(alg, %{"pem" => pem}, headers) when alg in @map_key_algorithms do 96 | raw_create( 97 | alg, 98 | headers |> transform_headers(alg) |> JWS.from_map(), 99 | JWK.from_pem(pem) 100 | ) 101 | end 102 | 103 | def create(alg, key, headers) when is_map(key) and alg in @map_key_algorithms do 104 | raw_create( 105 | alg, 106 | headers |> transform_headers(alg) |> JWS.from_map(), 107 | JWK.from_map(key) 108 | ) 109 | end 110 | 111 | def create(alg, _key, _headers) when alg in @map_key_algorithms, 112 | do: raise(Joken.Error, :algorithm_needs_key) 113 | 114 | def create(_, _, _), do: raise(Joken.Error, :unrecognized_algorithm) 115 | 116 | defp raw_create(alg, jws, jwk) do 117 | %__MODULE__{ 118 | jws: jws, 119 | jwk: jwk, 120 | alg: alg 121 | } 122 | end 123 | 124 | @doc """ 125 | Signs a map of claims with the given Joken.Signer. 126 | 127 | ## Examples 128 | 129 | iex> Joken.Signer.sign(%{"name" => "John Doe"}, Joken.Signer.create("HS256", "secret")) 130 | {:ok, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UifQ.xuEv8qrfXu424LZk8bVgr9MQJUIrp1rHcPyZw_KSsds"} 131 | 132 | iex> Joken.Signer.sign(%{"name" => "John Doe"}, Joken.Signer.parse_config(:rs256)) 133 | {:ok, "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UifQ.e3hyn_oaaA2lxMlqH1UPo8STN-a_sszl8B2_s6tY9aT_YBAmfd7BXJOPsOMl7x2wXeKMQaNBVjna2tA0UiO_m3SpwiYgoTcU65D6OgkzugmLD_DhjDK1YCOKlm7So1uhbkb_QCuo4Ij5scsQqwv7hkxo4IximGBeH9LAvPhPTaGmYJMI7_tWIld2TlY6tNUQP4n0qctXsI3hjvGzdvuQW-tRnzAQCC4TYe-mJgFa033NSHeiX-sZB-SuYlWi7DJqDTiwlb_beVdqWpxxtFDA005Iw6FZTpH9Rs1LVwJU5t3RN5iWB-z4ZI-kKsGUGLNrAZ7btV6Ow2FMAdj9TXmNpQ"} 134 | 135 | """ 136 | @spec sign(Joken.claims(), __MODULE__.t()) :: 137 | {:ok, Joken.bearer_token()} | {:error, Joken.error_reason()} 138 | def sign(claims, %__MODULE__{alg: _, jwk: jwk, jws: %JWS{alg: {alg, _}} = jws}) 139 | when is_map(claims) do 140 | with result = {%{alg: ^alg}, _} <- JWT.sign(jwk, jws, claims), 141 | {_, compacted_token} <- JWS.compact(result) do 142 | {:ok, compacted_token} 143 | end 144 | end 145 | 146 | @doc """ 147 | Verifies the given token's signature with the given `Joken.Signer`. 148 | 149 | ## Examples 150 | 151 | iex> Joken.Signer.verify("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UifQ.xuEv8qrfXu424LZk8bVgr9MQJUIrp1rHcPyZw_KSsds", Joken.Signer.create("HS256", "secret")) 152 | {:ok, %{"name" => "John Doe"}} 153 | 154 | iex> Joken.Signer.verify("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UifQ.e3hyn_oaaA2lxMlqH1UPo8STN-a_sszl8B2_s6tY9aT_YBAmfd7BXJOPsOMl7x2wXeKMQaNBVjna2tA0UiO_m3SpwiYgoTcU65D6OgkzugmLD_DhjDK1YCOKlm7So1uhbkb_QCuo4Ij5scsQqwv7hkxo4IximGBeH9LAvPhPTaGmYJMI7_tWIld2TlY6tNUQP4n0qctXsI3hjvGzdvuQW-tRnzAQCC4TYe-mJgFa033NSHeiX-sZB-SuYlWi7DJqDTiwlb_beVdqWpxxtFDA005Iw6FZTpH9Rs1LVwJU5t3RN5iWB-z4ZI-kKsGUGLNrAZ7btV6Ow2FMAdj9TXmNpQ", Joken.Signer.parse_config(:rs256)) 155 | {:ok, %{"name" => "John Doe"}} 156 | 157 | """ 158 | @spec verify(Joken.bearer_token(), %__MODULE__{}) :: 159 | {:ok, Joken.claims()} | {:error, Joken.error_reason()} 160 | def verify(token, %__MODULE__{alg: alg, jwk: jwk}) when is_binary(token) do 161 | case JWT.verify_strict(jwk, [alg], token) do 162 | {true, %JWT{fields: claims}, _} -> {:ok, claims} 163 | _ -> {:error, :signature_error} 164 | end 165 | end 166 | 167 | @doc """ 168 | Generates a `Joken.Signer` from Joken's application configuration. 169 | 170 | A `Joken.Signer` has an algorithm (one of #{inspect(@algorithms)}) and a key. 171 | 172 | There are several types of keys used by JWTs algorithms: 173 | 174 | - RSA 175 | - Elliptic Curve 176 | - Octet (binary) 177 | - So on... 178 | 179 | Also, they can be encoded in several ways: 180 | 181 | - Raw (map of parameters) 182 | - PEM (Privacy Enhanced Mail format) 183 | - Open SSH encoding 184 | - So on... 185 | 186 | To ease configuring these types of keys used by JWTs algorithms, Joken accepts a few 187 | parameters in its configuration: 188 | 189 | - `:signer_alg` : one of #{inspect(@algorithms)}. 190 | - `:key_pem` : a binary containing a key in PEM encoding format. 191 | - `:key_openssh` : a binary containing a key in Open SSH encoding format. 192 | - `:key_map` : a map with the raw parameters. 193 | - `:key_octet` : a binary used as the password for HS algorithms only. 194 | 195 | ## Examples 196 | 197 | config :joken, 198 | hs256: [ 199 | signer_alg: "HS256", 200 | key_octet: "test" 201 | ] 202 | 203 | config :joken, 204 | rs256: [ 205 | signer_alg: "RS256", 206 | key_pem: \"\"\" 207 | -----BEGIN RSA PRIVATE KEY----- 208 | 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== 209 | -----END RSA PRIVATE KEY----- 210 | \"\"\" 211 | ] 212 | 213 | """ 214 | @spec parse_config(atom()) :: __MODULE__.t() | nil 215 | def parse_config(key \\ :default_key) do 216 | case Application.get_env(:joken, key) do 217 | key_config when is_binary(key_config) -> 218 | create("HS256", key_config) 219 | 220 | key_config when is_list(key_config) -> 221 | parse_list_config(key_config) 222 | 223 | _ -> 224 | nil 225 | end 226 | end 227 | 228 | defp parse_list_config(config) do 229 | signer_alg = config[:signer_alg] || "HS256" 230 | headers = config[:jose_extra_headers] || %{} 231 | 232 | key_pem = 233 | case {config[:key_pem], config[:passphrase]} do 234 | {nil, _} -> nil 235 | {key, nil} -> key 236 | {key, password} -> {key, password} 237 | end 238 | 239 | key_map = config[:key_map] 240 | key_openssh = config[:key_openssh] 241 | key_octet = config[:key_octet] 242 | 243 | key_config = 244 | [ 245 | {&from_pem/1, key_pem}, 246 | {&JWK.from_map/1, key_map}, 247 | {&JWK.from_openssh_key/1, key_openssh}, 248 | {&JWK.from_oct/1, key_octet} 249 | ] 250 | |> Enum.filter(fn {_, val} -> not is_nil(val) end) 251 | 252 | unless Enum.count(key_config) == 1, do: raise(Joken.Error, :wrong_key_parameters) 253 | 254 | {jwk_function, value} = List.first(key_config) 255 | 256 | if signer_alg in @algorithms do 257 | raw_create( 258 | signer_alg, 259 | headers |> transform_headers(signer_alg) |> JWS.from_map(), 260 | jwk_function.(value) 261 | ) 262 | else 263 | raise Joken.Error, :unrecognized_algorithm 264 | end 265 | end 266 | 267 | defp from_pem({key, passphrase}), do: JWK.from_pem(passphrase, key) 268 | defp from_pem(key), do: JWK.from_pem(key) 269 | 270 | defp transform_headers(headers, signer_alg) when is_map(headers) and is_binary(signer_alg) do 271 | headers 272 | |> Map.put("alg", signer_alg) 273 | |> Map.put_new("typ", "JWT") 274 | end 275 | end 276 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Joken.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/joken-elixir/joken" 5 | @version "2.6.2" 6 | 7 | def project do 8 | [ 9 | app: :joken, 10 | version: @version, 11 | name: "Joken", 12 | elixir: "~> 1.13", 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | start_permanent: Mix.env() == :prod, 15 | consolidate_protocols: Mix.env() != :test, 16 | description: description(), 17 | package: package(), 18 | deps: deps(), 19 | docs: docs(), 20 | dialyzer: [plt_add_deps: :apps_direct, plt_add_apps: [:jason]], 21 | test_coverage: [tool: ExCoveralls], 22 | preferred_cli_env: [ 23 | coveralls: :test, 24 | "coveralls.github": :test, 25 | "coveralls.detail": :test, 26 | "coveralls.post": :test, 27 | "coveralls.html": :test 28 | ] 29 | ] 30 | end 31 | 32 | def application do 33 | [ 34 | extra_applications: [:logger, :crypto] 35 | ] 36 | end 37 | 38 | defp elixirc_paths(:test), do: ["lib", "test/support"] 39 | defp elixirc_paths(_), do: ["lib"] 40 | 41 | defp deps do 42 | [ 43 | {:jose, "~> 1.11.10"}, 44 | {:jason, "~> 1.4", only: [:dev, :test]}, 45 | {:benchee, "~> 1.3", only: :dev}, 46 | 47 | # Docs 48 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 49 | 50 | # Dialyzer 51 | {:dialyxir, "~> 1.4.0", only: [:dev, :test], runtime: false}, 52 | 53 | # Credo 54 | {:credo, "~> 1.7", only: :test, runtime: false}, 55 | 56 | # Test 57 | {:junit_formatter, "~> 3.4", only: :test}, 58 | {:stream_data, "~> 1.1", only: :test}, 59 | {:excoveralls, "~> 0.18", only: :test}, 60 | {:castore, "~> 1.0", only: :test} 61 | ] 62 | end 63 | 64 | defp description do 65 | """ 66 | JWT (JSON Web Token) library for Elixir. 67 | """ 68 | end 69 | 70 | defp package do 71 | [ 72 | files: ["lib", "mix.exs", "README.md", "LICENSE.txt", "CHANGELOG.md"], 73 | maintainers: ["Bryan Joseph", "Victor Nascimento"], 74 | licenses: ["Apache-2.0"], 75 | links: %{ 76 | "Changelog" => "https://hexdocs.pm/joken/changelog.html", 77 | "GitHub" => @source_url 78 | } 79 | ] 80 | end 81 | 82 | defp docs do 83 | [ 84 | extra_section: "GUIDES", 85 | extras: [ 86 | {:"CHANGELOG.md", [title: "Changelog"]}, 87 | {:"README.md", [title: "Readme"]}, 88 | "guides/introduction.md", 89 | "guides/configuration.md", 90 | "guides/signers.md", 91 | "guides/asymmetric_cryptography_signers.md", 92 | "guides/testing.md", 93 | "guides/common_use_cases.md", 94 | "guides/migration_from_1.md", 95 | "guides/custom_header_arguments.md" 96 | ], 97 | main: "readme", 98 | source_url: @source_url, 99 | source_ref: "v#{@version}" 100 | ] 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, 3 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 | "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, 5 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 6 | "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, 7 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 8 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 10 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 11 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 12 | "excoveralls": {:hex, :excoveralls, "0.18.2", "86efd87a0676a3198ff50b8c77620ea2f445e7d414afa9ec6c4ba84c9f8bdcc2", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "230262c418f0de64077626a498bd4fdf1126d5c2559bb0e6b43deac3005225a4"}, 13 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 14 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 15 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 16 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 17 | "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, 18 | "junit_formatter": {:hex, :junit_formatter, "3.4.0", "d0e8db6c34dab6d3c4154c3b46b21540db1109ae709d6cf99ba7e7a2ce4b1ac2", [:mix], [], "hexpm", "bb36e2ae83f1ced6ab931c4ce51dd3dbef1ef61bb4932412e173b0cfa259dacd"}, 19 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 20 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 21 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 22 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 23 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 24 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 25 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 26 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 27 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 28 | "stream_data": {:hex, :stream_data, "1.1.1", "fd515ca95619cca83ba08b20f5e814aaf1e5ebff114659dc9731f966c9226246", [:mix], [], "hexpm", "45d0cd46bd06738463fd53f22b70042dbb58c384bb99ef4e7576e7bb7d3b8c8c"}, 29 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 30 | } 31 | -------------------------------------------------------------------------------- /test/hooks/required_claims_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Joken.Hooks.RequiredClaimsTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "fails if required claim is missing - list of binaries" do 5 | defmodule MissingRequiredClaimAsListOfBinaries do 6 | use Joken.Config 7 | 8 | add_hook Joken.Hooks.RequiredClaims, ["claim1", "claim2"] 9 | 10 | def token_config, do: %{} 11 | end 12 | 13 | alias MissingRequiredClaimAsListOfBinaries, as: Config 14 | 15 | assert {:error, [message: "Invalid token", missing_claims: ["claim2"]]} == 16 | Config.generate_and_sign!(%{claim1: 1, claim3: 3}) 17 | |> Config.verify_and_validate() 18 | end 19 | 20 | test "succeeds if required claim is present - list of binaries" do 21 | defmodule RequiredClaimPresentAsListOfBinaries do 22 | use Joken.Config 23 | 24 | add_hook Joken.Hooks.RequiredClaims, ["claim1", "claim2"] 25 | 26 | def token_config, do: %{} 27 | end 28 | 29 | alias RequiredClaimPresentAsListOfBinaries, as: Config 30 | 31 | assert {:ok, %{"claim1" => 1, "claim2" => 2, "claim3" => 3}} == 32 | Config.generate_and_sign!(%{claim1: 1, claim2: 2, claim3: 3}) 33 | |> Config.verify_and_validate() 34 | end 35 | 36 | test "fails if required claim is missing - list of atoms" do 37 | defmodule MissingRequiredClaimAsListOfAtoms do 38 | use Joken.Config 39 | 40 | add_hook Joken.Hooks.RequiredClaims, [:claim1, :claim2] 41 | 42 | def token_config, do: %{} 43 | end 44 | 45 | alias MissingRequiredClaimAsListOfAtoms, as: Config 46 | 47 | assert {:error, [message: "Invalid token", missing_claims: ["claim2"]]} == 48 | Config.generate_and_sign!(%{claim1: 1, claim3: 3}) 49 | |> Config.verify_and_validate() 50 | end 51 | 52 | test "succeeds if required claim is present - list of atoms" do 53 | defmodule RequiredClaimPresentAsListOfAtoms do 54 | use Joken.Config 55 | 56 | add_hook Joken.Hooks.RequiredClaims, [:claim1, :claim2] 57 | 58 | def token_config, do: %{} 59 | end 60 | 61 | alias RequiredClaimPresentAsListOfAtoms, as: Config 62 | 63 | assert {:ok, %{"claim1" => 1, "claim2" => 2, "claim3" => 3}} == 64 | Config.generate_and_sign!(%{claim1: 1, claim2: 2, claim3: 3}) 65 | |> Config.verify_and_validate() 66 | end 67 | 68 | test "raises if missing options" do 69 | defmodule MissingRequiredClaimsOptions do 70 | use Joken.Config 71 | 72 | add_hook Joken.Hooks.RequiredClaims 73 | 74 | def token_config, do: %{} 75 | end 76 | 77 | alias MissingRequiredClaimsOptions, as: Config 78 | 79 | assert_raise RuntimeError, "Missing required claims options", fn -> 80 | Config.generate_and_sign!(%{claim1: 1, claim2: 2, claim3: 3}) 81 | |> Config.verify_and_validate() 82 | end 83 | end 84 | 85 | test "raises if options are not a list" do 86 | defmodule MissingRequiredClaimsOptionsNotAList do 87 | use Joken.Config 88 | 89 | add_hook Joken.Hooks.RequiredClaims, :my_option 90 | 91 | def token_config, do: %{} 92 | end 93 | 94 | alias MissingRequiredClaimsOptionsNotAList, as: Config 95 | 96 | assert_raise RuntimeError, "Options must be a list of claim keys", fn -> 97 | Config.generate_and_sign!(%{claim1: 1, claim2: 2, claim3: 3}) 98 | |> Config.verify_and_validate() 99 | end 100 | end 101 | 102 | test "raises if any of the keys is not an atom or string" do 103 | defmodule BadRequiredClaimsKeyOption do 104 | use Joken.Config 105 | 106 | add_hook Joken.Hooks.RequiredClaims, [:good_option, 1] 107 | 108 | def token_config, do: %{} 109 | end 110 | 111 | alias BadRequiredClaimsKeyOption, as: Config 112 | 113 | assert_raise FunctionClauseError, 114 | "no function clause matching in Joken.Hooks.RequiredClaims.map_keys/1", 115 | fn -> 116 | Config.generate_and_sign!(%{claim1: 1, claim2: 2, claim3: 3}) 117 | |> Config.verify_and_validate() 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/joken_claim_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Joken.ClaimTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Joken.Claim 5 | 6 | test "can generate values" do 7 | claim = %Claim{generate: fn -> "New Value" end} 8 | assert Claim.__generate_claim__({"val", claim}, %{}) == %{"val" => "New Value"} 9 | end 10 | 11 | test "when generate function is nil skips generation" do 12 | claim = %Claim{generate: nil} 13 | assert Claim.__generate_claim__({"val", claim}, %{}) == %{} 14 | end 15 | 16 | test "when generate function has arity different than 0 skips generation" do 17 | claim = %Claim{generate: fn _wth -> "nope won't do" end} 18 | assert Claim.__generate_claim__({"val", claim}, %{}) == %{} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/joken_config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Joken.Config.Test do 2 | use ExUnit.Case, async: true 3 | use ExUnitProperties 4 | alias Joken.{Config, CurrentTime.Mock, Error} 5 | 6 | setup do 7 | {:ok, _pid} = start_supervised(Mock) 8 | :ok 9 | end 10 | 11 | describe "Joken.Config.default_claims/1" do 12 | property "any given issuer will be validated" do 13 | check all(issuer <- binary()) do 14 | iss_claim = Config.default_claims(iss: issuer)["iss"] 15 | assert iss_claim.validate.(issuer, %{}, %{}) 16 | end 17 | end 18 | 19 | property "any given audience will be validated" do 20 | check all(audience <- binary()) do 21 | aud_claim = Config.default_claims(aud: audience)["aud"] 22 | assert aud_claim.validate.(audience, %{}, %{}) 23 | end 24 | end 25 | 26 | test "generates exp, iss, iat, nbf claims" do 27 | assert Config.default_claims() |> Map.keys() == ["aud", "exp", "iat", "iss", "jti", "nbf"] 28 | end 29 | 30 | test "can customize exp duration" do 31 | Mock.freeze() 32 | 33 | # 1 second 34 | exp_claim = Config.default_claims(default_exp: 1)["exp"] 35 | assert exp_claim.generate.() > Joken.current_time() 36 | 37 | # Zero seconds 38 | exp_claim = Config.default_claims(default_exp: 0)["exp"] 39 | assert exp_claim.generate.() <= Joken.current_time() 40 | end 41 | 42 | test "can skip claims" do 43 | keys = Config.default_claims(skip: [:exp]) |> Map.keys() 44 | assert keys == ["aud", "iat", "iss", "jti", "nbf"] 45 | 46 | keys = Config.default_claims(skip: [:exp, :iat]) |> Map.keys() 47 | assert keys == ["aud", "iss", "jti", "nbf"] 48 | 49 | assert Config.default_claims(skip: [:aud, :exp, :iat, :iss, :jti, :nbf]) == %{} 50 | end 51 | 52 | test "defaults audience and issuer to Joken" do 53 | claims = Config.default_claims() 54 | assert claims["aud"].generate.() == "Joken" 55 | assert claims["iss"].generate.() == "Joken" 56 | end 57 | 58 | test "can set a different audience and issuer" do 59 | claims = Config.default_claims(aud: "aud", iss: "iss") 60 | assert claims["aud"].generate.() == "aud" 61 | assert claims["iss"].generate.() == "iss" 62 | end 63 | 64 | test "default exp validates properly" do 65 | Mock.freeze() 66 | 67 | exp_claim = Config.default_claims()["exp"] 68 | # 1 second expiration 69 | assert exp_claim.validate.(Joken.current_time() + 1, %{}, %{}) 70 | 71 | # -1 second expiration (always expired) 72 | refute exp_claim.validate.(Joken.current_time() - 1, %{}, %{}) 73 | 74 | # 0 second expiration (always expired) 75 | refute exp_claim.validate.(Joken.current_time(), %{}, %{}) 76 | end 77 | 78 | test "default iss validates properly" do 79 | exp_claim = Config.default_claims()["iss"] 80 | assert exp_claim.validate.("Joken", %{}, %{}) 81 | refute exp_claim.validate.("Another", %{}, %{}) 82 | end 83 | 84 | test "default nbf validates properly" do 85 | Mock.freeze() 86 | exp_claim = Config.default_claims()["nbf"] 87 | 88 | # Not before current time 89 | assert exp_claim.validate.(Joken.current_time(), %{}, %{}) 90 | 91 | # not before a second ago 92 | assert exp_claim.validate.(Joken.current_time() - 1, %{}, %{}) 93 | 94 | # not before a second in the future 95 | refute exp_claim.validate.(Joken.current_time() + 1, %{}, %{}) 96 | end 97 | 98 | test "can switch default jti generation function" do 99 | jti_claim = Config.default_claims(generate_jti: fn -> "Hi" end)["jti"] 100 | 101 | assert jti_claim.generate.() == "Hi" 102 | end 103 | 104 | test "raises with invalid data types" do 105 | raise_fun = fn -> Config.default_claims(generate_jti: 123) end 106 | assert_raise Error, Error.message(%Error{reason: :invalid_default_claims}), raise_fun 107 | end 108 | end 109 | 110 | describe "add_claim" do 111 | test "must provide a validate function or a generate function" do 112 | assert_raise Error, Error.message(%Error{reason: :claim_configuration_not_valid}), fn -> 113 | Joken.Config.add_claim(%{}, "claim_key", nil, nil, []) 114 | end 115 | end 116 | 117 | test "validate_function must be of arity 1 or 2" do 118 | assert_raise Error, Error.message(%Error{reason: :bad_validate_fun_arity}), fn -> 119 | Joken.Config.add_claim( 120 | %{}, 121 | "claim_key", 122 | nil, 123 | fn _arg1, _arg2, _arg3, _arg4 -> true end, 124 | [] 125 | ) 126 | end 127 | end 128 | end 129 | 130 | describe "generate_and_sign/verify_and_update" do 131 | property "should always pass for the same signer" do 132 | generator = 133 | StreamData.map_of( 134 | StreamData.string(:ascii), 135 | StreamData.one_of([ 136 | StreamData.string(:ascii), 137 | StreamData.integer(), 138 | StreamData.boolean(), 139 | StreamData.map_of( 140 | StreamData.string(:ascii), 141 | StreamData.one_of([ 142 | StreamData.string(:ascii), 143 | StreamData.integer(), 144 | StreamData.boolean() 145 | ]) 146 | ) 147 | ]) 148 | ) 149 | 150 | defmodule PropertyEncodeDecode do 151 | use Joken.Config 152 | end 153 | 154 | check all(input_map <- generator) do 155 | {:ok, token, gen_claims} = PropertyEncodeDecode.generate_and_sign(input_map) 156 | {:ok, claims} = PropertyEncodeDecode.verify_and_validate(token) 157 | 158 | assert claims == gen_claims 159 | assert_map_contains_other(claims, input_map) 160 | end 161 | end 162 | end 163 | 164 | defp assert_map_contains_other(target, contains_map) do 165 | contains_map 166 | |> Enum.each(fn 167 | {"", _val} -> 168 | :ok 169 | 170 | {key, value} -> 171 | result = Map.fetch(target, key) 172 | 173 | case result do 174 | {:ok, cur_value} when value == cur_value -> 175 | :ok 176 | 177 | {:ok, cur_value} when value != cur_value -> 178 | raise """ 179 | Value for key #{key} differs. 180 | 181 | Expected: #{inspect(value)} 182 | Got: #{inspect(cur_value)} 183 | """ 184 | 185 | val -> 186 | raise """ 187 | Expected value differs. 188 | 189 | Got: #{inspect(val)}. 190 | """ 191 | end 192 | end) 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /test/joken_hooks_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Joken.HooksTest do 2 | use ExUnit.Case, async: true 3 | import ExUnit.CaptureIO 4 | 5 | alias Joken.{CurrentTime.Mock, Signer} 6 | 7 | setup do 8 | {:ok, _pid} = start_supervised(Mock) 9 | :ok 10 | end 11 | 12 | defmodule TestHook do 13 | use Joken.Hooks 14 | 15 | @impl Joken.Hooks 16 | def before_sign(_options, {claims, signer}) do 17 | IO.puts("TestHook.before_sign/4") 18 | {:cont, {claims, signer}} 19 | end 20 | end 21 | 22 | test "can add hook" do 23 | defmodule AddHookTest do 24 | use Joken.Config 25 | 26 | add_hook(TestHook) 27 | end 28 | 29 | assert AddHookTest.__hooks__() == [{TestHook, []}, AddHookTest] 30 | end 31 | 32 | test "own hooks are executed" do 33 | defmodule OwnHookIsExecuted do 34 | use Joken.Config 35 | 36 | @impl Joken.Hooks 37 | def before_generate(_options, {claims_config, extra_claims}) do 38 | IO.puts("before_generate") 39 | {:cont, {claims_config, extra_claims}} 40 | end 41 | end 42 | 43 | assert capture_io(&OwnHookIsExecuted.generate_and_sign/0) == "before_generate\n" 44 | end 45 | 46 | test "all hooks are executed" do 47 | defmodule AddedHooksAreExecuted do 48 | use Joken.Config 49 | 50 | add_hook(TestHook) 51 | 52 | @impl Joken.Hooks 53 | def before_generate(_options, {claims_config, extra_claims}) do 54 | IO.puts("before_generate") 55 | {:cont, {claims_config, extra_claims}} 56 | end 57 | end 58 | 59 | assert capture_io(&AddedHooksAreExecuted.generate_and_sign/0) == 60 | "before_generate\nTestHook.before_sign/4\n" 61 | end 62 | 63 | test "before_hook can abort execution" do 64 | defmodule BeforeHookCanAbort do 65 | use Joken.Config 66 | 67 | @impl Joken.Hooks 68 | def before_sign(_options, _input) do 69 | {:halt, {:error, :abort}} 70 | end 71 | end 72 | 73 | assert BeforeHookCanAbort.generate_and_sign() == {:error, :abort} 74 | end 75 | 76 | test "after_hook can abort execution" do 77 | defmodule AfterHookCanAbort do 78 | use Joken.Config 79 | 80 | @impl Joken.Hooks 81 | def after_sign(_options, _result, _input) do 82 | {:halt, {:error, :abort}} 83 | end 84 | end 85 | 86 | assert AfterHookCanAbort.generate_and_sign() == {:error, :abort} 87 | end 88 | 89 | test "wrong callback returns :unexpected" do 90 | defmodule WrongCallbackReturn do 91 | use Joken.Config 92 | 93 | @impl Joken.Hooks 94 | def after_sign(_options, _result, _input), do: :ok 95 | end 96 | 97 | assert WrongCallbackReturn.generate_and_sign() == {:error, :wrong_hook_return} 98 | end 99 | 100 | test "can add hook with options" do 101 | defmodule HookWithOptions do 102 | use Joken.Hooks 103 | 104 | @impl true 105 | def before_generate(options, {token_config, extra_claims}) do 106 | IO.puts("Run with options: #{inspect(options)}") 107 | {:cont, {token_config, extra_claims}} 108 | end 109 | end 110 | 111 | defmodule UseHookWithOptions do 112 | use Joken.Config 113 | 114 | add_hook(HookWithOptions, option1: 1) 115 | 116 | def token_config, do: %{} 117 | end 118 | 119 | assert capture_io(&UseHookWithOptions.generate_and_sign!/0) == 120 | "Run with options: [option1: 1]\n" 121 | end 122 | 123 | @tag :capture_log 124 | test "error in validate propagates to after_validate" do 125 | defmodule ValidateErrorHook do 126 | use Joken.Hooks 127 | 128 | @impl true 129 | def after_validate(_options, {:error, reason}, _input) do 130 | IO.puts("Got error: #{inspect(reason)}") 131 | {:halt, {:error, :validate_error}} 132 | end 133 | end 134 | 135 | defmodule UseValidateErrorHook do 136 | use Joken.Config 137 | 138 | add_hook(ValidateErrorHook) 139 | 140 | def token_config do 141 | %{} 142 | |> add_claim("test", fn -> "TEST" end, &(&1 == "PRODUCTION")) 143 | end 144 | end 145 | 146 | token = UseValidateErrorHook.generate_and_sign!() 147 | 148 | fun = fn -> 149 | assert UseValidateErrorHook.verify_and_validate(token) == {:error, :validate_error} 150 | end 151 | 152 | assert capture_io(fun) == 153 | "Got error: [message: \"Invalid token\", claim: \"test\", claim_val: \"TEST\"]\n" 154 | end 155 | 156 | test "empty hooks is a pass through implementation" do 157 | # no overridden callback 158 | defmodule(EmptyHook, do: use(Joken.Hooks)) 159 | 160 | defmodule TokenWithEmptyHook do 161 | use Joken.Config 162 | add_hook(EmptyHook) 163 | end 164 | 165 | assert %{"iss" => "Joken", "aud" => "Joken"} = 166 | TokenWithEmptyHook.generate_and_sign!() 167 | |> TokenWithEmptyHook.verify_and_validate!() 168 | end 169 | 170 | test "after callbacks can set validation" do 171 | defmodule TokenWithOverridenAfterHook do 172 | use Joken.Config 173 | 174 | def after_validate(_, {:ok, _}, input) do 175 | {:cont, {:error, :invalid}, input} 176 | end 177 | end 178 | 179 | assert {:error, :invalid} == 180 | TokenWithOverridenAfterHook.generate_and_sign!() 181 | |> TokenWithOverridenAfterHook.verify_and_validate() 182 | end 183 | 184 | test "after verify receives signing error" do 185 | defmodule AfterVerifyTokenError do 186 | use Joken.Config 187 | 188 | def after_verify(_, result, input) do 189 | assert result == {:error, :signature_error} 190 | {:cont, result, input} 191 | end 192 | end 193 | 194 | signer = Signer.create("HS256", "another key whatever") 195 | 196 | assert {:error, :signature_error} == 197 | AfterVerifyTokenError.generate_and_sign!() 198 | |> AfterVerifyTokenError.verify_and_validate(signer) 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /test/joken_signer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Joken.Signer.Test do 2 | use ExUnit.Case, async: true 3 | alias Joken.{Error, Signer} 4 | 5 | doctest Signer 6 | 7 | # Tests below "may" break in future OTP versions. This is related to the 8 | # supported algorithms in the crypto module. Current expected behaviour is 9 | # that it will fail since OTP 20 does not support the needed algorithms. 10 | 11 | # !!! Because of a bug with JOSE we can't parse OpenSSH keys properly =/ !!! 12 | # https://github.com/potatosalad/erlang-jose/issues/96 13 | 14 | # test "can parse a signer with a private OpenSSH key" do 15 | # JOSE.crypto_fallback(true) 16 | # on_exit(fn -> JOSE.crypto_fallback(false) end) 17 | 18 | # assert %Signer{alg: "Ed25519"} = Signer.parse_config(:ed25519) 19 | # end 20 | 21 | # No fallback, no cake 22 | # test "fails to parse private OpenSSH key if crypto fallback is false" do 23 | # JOSE.crypto_fallback(false) 24 | 25 | # # We are not trying to translate this very specific error to Joken.Error 26 | # # because it is a rarely used and would add plenty of clutter to the call 27 | # # stack 28 | # assert_raise ErlangError, fn -> 29 | # Signer.parse_config(:ed25519) 30 | # end 31 | # end 32 | 33 | test "can create a signer with alg and pem" do 34 | pem = Application.get_env(:joken, :pem_rs256)[:key_pem] 35 | signer = Signer.create("RS512", %{"pem" => pem}) 36 | 37 | assert %Signer{ 38 | alg: "RS512", 39 | jws: %JOSE.JWS{ 40 | alg: {:jose_jws_alg_rsa_pkcs1_v1_5, :RS512} 41 | }, 42 | jwk: %JOSE.JWK{} 43 | } = signer 44 | end 45 | 46 | test "can create a signer with only the public key" do 47 | pem = Application.get_env(:joken, :public_pem)[:key_pem] 48 | signer = Signer.create("RS256", %{"pem" => pem}) 49 | 50 | assert %Signer{ 51 | alg: "RS256", 52 | jws: %JOSE.JWS{ 53 | alg: {:jose_jws_alg_rsa_pkcs1_v1_5, :RS256} 54 | }, 55 | jwk: %JOSE.JWK{} 56 | } = signer 57 | end 58 | 59 | test "can create a signer from an EC private key" do 60 | pem = Application.get_env(:joken, :pem_es256)[:key_pem] 61 | signer = Signer.create("ES256", %{"pem" => pem}) 62 | 63 | assert %Signer{ 64 | alg: "ES256", 65 | jws: %JOSE.JWS{ 66 | alg: {:jose_jws_alg_ecdsa, :ES256} 67 | }, 68 | jwk: %JOSE.JWK{} 69 | } = signer 70 | end 71 | 72 | test "can create a signer from an encrypted key" do 73 | pem = Application.get_env(:joken, :pem_encrypted_rs256)[:key_pem] 74 | passphrase = Application.get_env(:joken, :pem_encrypted_rs256)[:passphrase] 75 | signer = Signer.create("RS256", %{"pem" => pem, "passphrase" => passphrase}) 76 | 77 | assert %Signer{ 78 | alg: "RS256", 79 | jws: %JOSE.JWS{ 80 | alg: {:jose_jws_alg_rsa_pkcs1_v1_5, :RS256} 81 | }, 82 | jwk: %JOSE.JWK{} 83 | } = signer 84 | end 85 | 86 | test "can create a signer from config using an encrypted key" do 87 | assert %Signer{ 88 | alg: "RS256", 89 | jws: %JOSE.JWS{ 90 | alg: {:jose_jws_alg_rsa_pkcs1_v1_5, :RS256} 91 | }, 92 | jwk: %JOSE.JWK{} 93 | } = Signer.parse_config(:pem_encrypted_rs256) 94 | end 95 | 96 | test "can create a signer from a map of a key" do 97 | map = Application.get_env(:joken, :rs256)[:key_map] 98 | signer = Signer.create("RS256", map) 99 | 100 | assert %Signer{ 101 | alg: "RS256", 102 | jws: %JOSE.JWS{ 103 | alg: {:jose_jws_alg_rsa_pkcs1_v1_5, :RS256} 104 | }, 105 | jwk: %JOSE.JWK{} 106 | } = signer 107 | end 108 | 109 | test "raise with invalid parameter" do 110 | assert_raise Error, Error.message(%Error{reason: :algorithm_needs_key}), fn -> 111 | Signer.create("RS256", "Not a map") 112 | end 113 | end 114 | 115 | test "raise with invalid algorithm" do 116 | assert_raise Error, Error.message(%Error{reason: :unrecognized_algorithm}), fn -> 117 | Signer.create("any algorithm", %{}) 118 | end 119 | end 120 | 121 | test "raise when key is invalid" do 122 | assert_raise Error, Error.message(%Error{reason: :algorithm_needs_binary_key}), fn -> 123 | Signer.create("HS256", %{}) 124 | end 125 | end 126 | 127 | test "raise when parsing invalid algorithm from configuration" do 128 | assert_raise Error, Error.message(%Error{reason: :unrecognized_algorithm}), fn -> 129 | Signer.parse_config(:bad_algorithm) 130 | end 131 | end 132 | 133 | test "raise with missing parameters" do 134 | assert_raise Error, Error.message(%Error{reason: :wrong_key_parameters}), fn -> 135 | Signer.parse_config(:missing_config_key) 136 | end 137 | end 138 | 139 | test "return error with wrong signer for token" do 140 | valid_signer = Signer.create("HS256", "secret") 141 | invalid_signer = Signer.create("HS256", "otherSecret") 142 | 143 | {:ok, token, _claims} = Joken.encode_and_sign(%{}, valid_signer) 144 | assert {:error, :signature_error} == Joken.verify(token, invalid_signer) 145 | end 146 | 147 | test "return error with invalid signer" do 148 | assert {:error, :empty_signer} == Joken.encode_and_sign(%{}, %Signer{}) 149 | end 150 | 151 | test "can set key id on signer" do 152 | key_id = "kid" 153 | signer = Signer.create("HS256", "secret", %{"kid" => key_id}) 154 | 155 | {:ok, token, _claims} = Joken.encode_and_sign(%{}, signer) 156 | assert {:ok, %{"kid" => ^key_id, "alg" => "HS256"}} = Joken.peek_header(token) 157 | end 158 | 159 | test "can parse with key_id" do 160 | {:ok, token, _claims} = Joken.encode_and_sign(%{}, Signer.parse_config(:with_key_id)) 161 | 162 | assert {:ok, %{"kid" => "my_key_id", "alg" => "HS256"}} = Joken.peek_header(token) 163 | end 164 | 165 | test "can override typ header claim" do 166 | signer = Signer.create("HS256", "secret", %{"typ" => "SOMETHING_ELSE"}) 167 | {:ok, token, _claims} = Joken.encode_and_sign(%{}, signer) 168 | assert {:ok, %{"typ" => "SOMETHING_ELSE"}} = Joken.peek_header(token) 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /test/joken_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JokenTest do 2 | use ExUnit.Case, async: true 3 | import ExUnit.CaptureLog 4 | import Joken.Config, only: [add_claim: 4, add_claim: 5] 5 | alias Joken.CurrentTime.Mock 6 | 7 | setup do 8 | {:ok, _pid} = start_supervised(Mock) 9 | :ok 10 | end 11 | 12 | defmodule EmptyToken do 13 | use Joken.Config 14 | 15 | def token_config, do: %{} 16 | end 17 | 18 | describe "token introspection" do 19 | test "can peek header" do 20 | jwt = EmptyToken.generate_and_sign!() 21 | assert Joken.peek_header(jwt) == {:ok, %{"typ" => "JWT", "alg" => "HS256"}} 22 | end 23 | 24 | test "peek header passes error up with invalid token" do 25 | jwt = "not a token" 26 | assert Joken.peek_header(jwt) == {:error, :token_malformed} 27 | end 28 | 29 | test "can peek body" do 30 | custom_claims = %{"my" => "claim"} 31 | jwt = EmptyToken.generate_and_sign!(custom_claims) 32 | assert Joken.peek_claims(jwt) == {:ok, custom_claims} 33 | end 34 | 35 | test "peek body passes error up with invalid token" do 36 | jwt = "not a token" 37 | assert Joken.peek_claims(jwt) == {:error, :token_malformed} 38 | end 39 | end 40 | 41 | describe "signer key" do 42 | test "can verify a jwt with a signer key" do 43 | jwt = EmptyToken.generate_and_sign!() 44 | 45 | assert {:ok, %{}} == Joken.verify(jwt, :default_signer) 46 | end 47 | 48 | test "can sign a jwt with a signer key" do 49 | assert Joken.encode_and_sign(%{}, :default_signer) == 50 | {:ok, 51 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.mwiDnq8rTFp5Oyy5i7pT8qktTB4tZOAfiJXTEbEqn2g", 52 | %{}} 53 | end 54 | 55 | test "raises when signer key does not exist" do 56 | assert_raise Joken.Error, 57 | Joken.Error.message(%Joken.Error{reason: :no_default_signer}), 58 | fn -> 59 | Joken.encode_and_sign(%{}, :non_existent_signer) 60 | end 61 | end 62 | end 63 | 64 | describe "claim validation" do 65 | test "debug message is shown when claim validation fails" do 66 | token_config = 67 | %{} 68 | |> add_claim("iss", fn -> "not someone" end, fn val -> 69 | val == "not someone" 70 | end) 71 | 72 | validate_fun = fn -> 73 | assert {:error, [message: "Invalid token", claim: "iss", claim_val: "someone"]} == 74 | Joken.validate(token_config, %{"iss" => "someone"}, %{}) 75 | end 76 | 77 | assert capture_log(validate_fun) =~ 78 | "Claim %{\"iss\" => \"someone\"} did not pass validation.\n\nCurrent time: " 79 | end 80 | 81 | @tag :capture_log 82 | test "claim attaches custom error message" do 83 | custom_error_message = "Someone should not be there" 84 | 85 | token_config = 86 | %{} 87 | |> add_claim( 88 | "iss", 89 | fn -> "not someone" end, 90 | fn val -> 91 | val == "not someone" 92 | end, 93 | message: custom_error_message 94 | ) 95 | 96 | assert {:error, [message: custom_error_message, claim: "iss", claim_val: "someone"]} == 97 | Joken.validate(token_config, %{"iss" => "someone"}, %{}) 98 | end 99 | 100 | test "can make multi claim validation" do 101 | token_config = %{} |> add_claim("claim1", nil, &(&1 == &2["claim2"])) 102 | 103 | assert {:ok, %{"claim2" => "value", "claim1" => "value"}} == 104 | Joken.validate(token_config, %{"claim2" => "value", "claim1" => "value"}, %{}) 105 | end 106 | end 107 | 108 | describe "error" do 109 | test "is raised when generate_and_sign! returns error" do 110 | defmodule BadBeforeGenerate do 111 | use Joken.Hooks 112 | 113 | @impl true 114 | def before_generate(_opts, _input), 115 | do: {:halt, {:error, :my_reason}} 116 | end 117 | 118 | assert_raise( 119 | Joken.Error, 120 | "Error while calling `generate_and_sign!`. Reason: :my_reason.\n", 121 | fn -> 122 | Joken.generate_and_sign!(nil, nil, nil, [BadBeforeGenerate]) 123 | end 124 | ) 125 | end 126 | 127 | test "is raised when verify_and_validate! returns error" do 128 | defmodule BadBeforeVerify do 129 | use Joken.Hooks 130 | 131 | @impl true 132 | def before_verify(_opts, _input), 133 | do: {:halt, {:error, :my_reason}} 134 | end 135 | 136 | assert_raise( 137 | Joken.Error, 138 | "Error while calling `verify_and_validate!`. Reason: :my_reason.\n", 139 | fn -> 140 | Joken.verify_and_validate!(nil, "", nil, nil, [BadBeforeVerify]) 141 | end 142 | ) 143 | end 144 | end 145 | 146 | test "can expand a proper token" do 147 | {:ok, jwt, _} = 148 | Joken.encode_and_sign(%{}, Joken.Signer.create("HS256", "secret", %{"kid" => "my_id"})) 149 | 150 | assert {:ok, 151 | %{ 152 | "payload" => "e30", 153 | "protected" => "eyJhbGciOiJIUzI1NiIsImtpZCI6Im15X2lkIiwidHlwIjoiSldUIn0", 154 | "signature" => "MLp3PksT6p8kLN_7Hz_0t51aTNMqjBGZLsEr2admKVI" 155 | }} = Joken.expand(jwt) 156 | end 157 | 158 | test "returns error while trying to expand malformed token" do 159 | assert {:error, :token_malformed} == Joken.expand("asd") 160 | end 161 | 162 | test "custom signer is accepted for generate_and_sign" do 163 | signer = Joken.Signer.create("HS256", "custom signer") 164 | custom_claim = Joken.CurrentTime.OS.current_time() 165 | 166 | assert token = Joken.generate_and_sign!(%{}, %{"some" => custom_claim}, signer) 167 | assert Joken.peek_claims(token) == {:ok, %{"some" => custom_claim}} 168 | end 169 | 170 | test "peek_header and peek_claims give proper error upon improper token, instead of returning out of spec :error" do 171 | # This test ensures that peek_header and peek_claims use Base.url_decode64 properly 172 | assert {:error, :token_malformed} = Joken.peek_claims(".a.") 173 | assert {:error, :token_malformed} = Joken.peek_header("a..") 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /test/support/mock_current_time.ex: -------------------------------------------------------------------------------- 1 | defmodule Joken.CurrentTime.Mock do 2 | @moduledoc "Mock implementation of current time with time freezing." 3 | 4 | use Agent 5 | 6 | def start_link(name) do 7 | Agent.start_link( 8 | fn -> 9 | %{is_frozen: false, frozen_value: nil} 10 | end, 11 | name: name 12 | ) 13 | end 14 | 15 | def child_spec(_args) do 16 | %{ 17 | id: Joken.CurrentTime.Mock, 18 | start: {Joken.CurrentTime.Mock, :start_link, [unique_name_per_process()]} 19 | } 20 | end 21 | 22 | def current_time do 23 | state = Agent.get(unique_name_per_process(), fn state -> state end) 24 | 25 | if state[:is_frozen] do 26 | state[:frozen_value] 27 | else 28 | :os.system_time(:second) 29 | end 30 | end 31 | 32 | def freeze do 33 | freeze(:os.system_time(:second)) 34 | end 35 | 36 | def freeze(timestamp) do 37 | Agent.update(unique_name_per_process(), fn _state -> 38 | %{is_frozen: true, frozen_value: timestamp} 39 | end) 40 | end 41 | 42 | def unfreeze do 43 | Agent.update(unique_name_per_process(), fn _state -> 44 | %{is_frozen: false, frozen_value: nil} 45 | end) 46 | end 47 | 48 | def unique_name_per_process do 49 | binary_pid = 50 | self() 51 | |> :erlang.pid_to_list() 52 | |> :erlang.iolist_to_binary() 53 | 54 | "{__MODULE__}_#{binary_pid}" |> String.to_atom() 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | File.mkdir_p(Path.dirname(JUnitFormatter.get_report_file_path())) 2 | ExUnit.configure(formatters: [JUnitFormatter, ExUnit.CLIFormatter]) 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /test/use_config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Joken.UseConfig.Test do 2 | use ExUnit.Case, async: true 3 | use ExUnitProperties 4 | 5 | alias Joken.CurrentTime.Mock 6 | alias Joken.Signer 7 | 8 | setup do 9 | {:ok, _pid} = start_supervised(Mock) 10 | :ok 11 | end 12 | 13 | describe "__MODULE__.generate_and_sign" do 14 | test "can use default signer configuration" do 15 | defmodule DefaultSignerConfig do 16 | use Joken.Config 17 | 18 | def token_config, do: %{} 19 | end 20 | 21 | assert DefaultSignerConfig.generate_and_sign() == 22 | {:ok, 23 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.mwiDnq8rTFp5Oyy5i7pT8qktTB4tZOAfiJXTEbEqn2g", 24 | %{}} 25 | end 26 | 27 | test "can pass specific signer" do 28 | defmodule SpecificSignerConfig do 29 | use Joken.Config, default_signer: :hs256 30 | 31 | def token_config, do: %{} 32 | end 33 | 34 | assert SpecificSignerConfig.generate_and_sign() == 35 | {:ok, 36 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.P4Lqll22jQQJ1eMJikvNg5HKG-cKB0hUZA9BZFIG7Jk", 37 | %{}} 38 | end 39 | 40 | test "can pass a `Joken.Signer` instance" do 41 | defmodule SignerInstanceConfig do 42 | use Joken.Config 43 | 44 | def token_config, do: %{} 45 | end 46 | 47 | signer = Joken.Signer.create("HS256", "s3cret") 48 | 49 | assert SignerInstanceConfig.generate_and_sign(%{}, signer) == 50 | {:ok, 51 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.JXJ_RWHq_C9ZJbkrRGRg7NxSFm2hnVu5ToEa8Nx6OiU", 52 | %{}} 53 | end 54 | 55 | test "can receive extra claims" do 56 | defmodule ExtraClaimsConfig do 57 | use Joken.Config 58 | 59 | def token_config, do: %{} 60 | end 61 | 62 | assert ExtraClaimsConfig.generate_and_sign(%{"name" => "John Doe"}) == 63 | {:ok, 64 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UifQ.YSy8oSoFcYMXK2Gn2vcdsSRGtxnYHQ1KGeVOHO_tSbc", 65 | %{"name" => "John Doe"}} 66 | end 67 | end 68 | 69 | describe "__MODULE__.verify_and_validate" do 70 | test "can verify and validate a generated token" do 71 | defmodule(SimpleVerifyAndValidate, do: use(Joken.Config)) 72 | 73 | jwt = SimpleVerifyAndValidate.generate_and_sign!() 74 | 75 | assert {:ok, _claims} = SimpleVerifyAndValidate.verify_and_validate(jwt) 76 | end 77 | 78 | test "can validate a token with a context" do 79 | defmodule ValidateWithContext do 80 | use Joken.Config 81 | 82 | def token_config do 83 | %{} 84 | # Validate function with arity 2 85 | |> add_claim("custom", fn -> "custom" end, fn val, _claims, ctx -> val == ctx.custom end) 86 | end 87 | end 88 | 89 | jwt = ValidateWithContext.generate_and_sign!() 90 | 91 | assert {:ok, %{"custom" => "custom"}} = 92 | ValidateWithContext.verify_and_validate( 93 | jwt, 94 | ValidateWithContext.__default_signer__(), 95 | %{custom: "custom"} 96 | ) 97 | end 98 | 99 | test "can validate a token with a signer generated only with the public key" do 100 | defmodule OnlyPublicKeyValidate do 101 | use Joken.Config 102 | 103 | def token_config, do: %{} 104 | end 105 | 106 | # Private signer generates 107 | private_pem = Application.get_env(:joken, :pem_rs256)[:key_pem] 108 | private_signer = Signer.create("RS256", %{"pem" => private_pem}) 109 | 110 | assert token = OnlyPublicKeyValidate.generate_and_sign!(%{}, private_signer) 111 | 112 | # Public signer validates 113 | public_pem = Application.get_env(:joken, :public_pem)[:key_pem] 114 | public_signer = Signer.create("RS256", %{"pem" => public_pem}) 115 | 116 | assert OnlyPublicKeyValidate.verify_and_validate(token, public_signer) == {:ok, %{}} 117 | end 118 | 119 | test "can pass a `Joken.Signer` instance" do 120 | defmodule ValidateWithSigner do 121 | use Joken.Config 122 | 123 | def token_config, do: %{} 124 | end 125 | 126 | token = 127 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.JXJ_RWHq_C9ZJbkrRGRg7NxSFm2hnVu5ToEa8Nx6OiU" 128 | 129 | signer = Joken.Signer.create("HS256", "s3cret") 130 | 131 | assert ValidateWithSigner.verify_and_validate(token, signer) == {:ok, %{}} 132 | end 133 | end 134 | 135 | describe "__MODULE__.verify_and_validate!" do 136 | test "can verify and validate a generated token" do 137 | defmodule(SimpleVerifyAndValidateRaise, do: use(Joken.Config)) 138 | 139 | jwt = SimpleVerifyAndValidateRaise.generate_and_sign!() 140 | 141 | assert %{} = SimpleVerifyAndValidateRaise.verify_and_validate!(jwt) 142 | end 143 | 144 | test "can validate a token with a context" do 145 | defmodule ValidateWithContextRaise do 146 | use Joken.Config 147 | 148 | def token_config do 149 | %{} 150 | # Validate function with arity 2 151 | |> add_claim("custom", fn -> "custom" end, fn val, _claims, ctx -> val == ctx.custom end) 152 | end 153 | end 154 | 155 | jwt = ValidateWithContextRaise.generate_and_sign!() 156 | 157 | assert %{"custom" => "custom"} = 158 | ValidateWithContextRaise.verify_and_validate!( 159 | jwt, 160 | ValidateWithContextRaise.__default_signer__(), 161 | %{custom: "custom"} 162 | ) 163 | end 164 | 165 | test "can pass a `Joken.Signer` instance" do 166 | defmodule ValidateWithSignerRaise do 167 | use Joken.Config 168 | 169 | def token_config, do: %{} 170 | end 171 | 172 | token = 173 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.JXJ_RWHq_C9ZJbkrRGRg7NxSFm2hnVu5ToEa8Nx6OiU" 174 | 175 | signer = Joken.Signer.create("HS256", "s3cret") 176 | 177 | assert ValidateWithSignerRaise.verify_and_validate!(token, signer) == %{} 178 | end 179 | end 180 | end 181 | --------------------------------------------------------------------------------