├── .clj-kondo └── config.edn ├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.org ├── doc └── intro.md ├── project.clj ├── resources └── cert │ ├── gen_cert.sh │ ├── jwt-key-1.key │ ├── jwt-key-1.pub │ ├── jwt-key-2.key │ ├── jwt-key-2.pub │ ├── jwt-key-3.key │ ├── jwt-key-3.pub │ └── ring-jwt-middleware.csr ├── src └── ring_jwt_middleware │ ├── config.clj │ ├── core.clj │ ├── result.clj │ └── schemas.clj └── test └── ring_jwt_middleware ├── config_test.clj └── core_test.clj /.clj-kondo/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as 2 | {ring-jwt-middleware.result/let-either clojure.core/let}} 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 5 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Cache project dependencies 19 | uses: actions/cache@v4 20 | with: 21 | path: ~/.m2/repository 22 | key: ${{ runner.os }}-clojure-${{ hashFiles('**/project.clj') }} 23 | restore-keys: | 24 | ${{ runner.os }}-clojure 25 | - name: Prepare java 26 | uses: actions/setup-java@v4 27 | with: 28 | distribution: 'temurin' 29 | java-version: 21 30 | - name: Install clojure tools 31 | uses: DeLaGuardo/setup-clojure@12.5 32 | with: 33 | lein: latest 34 | - run: lein do clean, javac, test :all 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | .hgignore 11 | .hg/ 12 | .projectile 13 | .*.swp 14 | .clj-kondo/.cache/ 15 | .lsp/.cache/ 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC 2 | LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM 3 | CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 4 | 5 | 1. DEFINITIONS 6 | 7 | "Contribution" means: 8 | 9 | a) in the case of the initial Contributor, the initial code and 10 | documentation distributed under this Agreement, and 11 | 12 | b) in the case of each subsequent Contributor: 13 | 14 | i) changes to the Program, and 15 | 16 | ii) additions to the Program; 17 | 18 | where such changes and/or additions to the Program originate from and are 19 | distributed by that particular Contributor. A Contribution 'originates' from 20 | a Contributor if it was added to the Program by such Contributor itself or 21 | anyone acting on such Contributor's behalf. Contributions do not include 22 | additions to the Program which: (i) are separate modules of software 23 | distributed in conjunction with the Program under their own license 24 | agreement, and (ii) are not derivative works of the Program. 25 | 26 | "Contributor" means any person or entity that distributes the Program. 27 | 28 | "Licensed Patents" mean patent claims licensable by a Contributor which are 29 | necessarily infringed by the use or sale of its Contribution alone or when 30 | combined with the Program. 31 | 32 | "Program" means the Contributions distributed in accordance with this 33 | Agreement. 34 | 35 | "Recipient" means anyone who receives the Program under this Agreement, 36 | including all Contributors. 37 | 38 | 2. GRANT OF RIGHTS 39 | 40 | a) Subject to the terms of this Agreement, each Contributor hereby grants 41 | Recipient a non-exclusive, worldwide, royalty-free copyright license to 42 | reproduce, prepare derivative works of, publicly display, publicly perform, 43 | distribute and sublicense the Contribution of such Contributor, if any, and 44 | such derivative works, in source code and object code form. 45 | 46 | b) Subject to the terms of this Agreement, each Contributor hereby grants 47 | Recipient a non-exclusive, worldwide, royalty-free patent license under 48 | Licensed Patents to make, use, sell, offer to sell, import and otherwise 49 | transfer the Contribution of such Contributor, if any, in source code and 50 | object code form. This patent license shall apply to the combination of the 51 | Contribution and the Program if, at the time the Contribution is added by the 52 | Contributor, such addition of the Contribution causes such combination to be 53 | covered by the Licensed Patents. The patent license shall not apply to any 54 | other combinations which include the Contribution. No hardware per se is 55 | licensed hereunder. 56 | 57 | c) Recipient understands that although each Contributor grants the licenses 58 | to its Contributions set forth herein, no assurances are provided by any 59 | Contributor that the Program does not infringe the patent or other 60 | intellectual property rights of any other entity. Each Contributor disclaims 61 | any liability to Recipient for claims brought by any other entity based on 62 | infringement of intellectual property rights or otherwise. As a condition to 63 | exercising the rights and licenses granted hereunder, each Recipient hereby 64 | assumes sole responsibility to secure any other intellectual property rights 65 | needed, if any. For example, if a third party patent license is required to 66 | allow Recipient to distribute the Program, it is Recipient's responsibility 67 | to acquire that license before distributing the Program. 68 | 69 | d) Each Contributor represents that to its knowledge it has sufficient 70 | copyright rights in its Contribution, if any, to grant the copyright license 71 | set forth in this Agreement. 72 | 73 | 3. REQUIREMENTS 74 | 75 | A Contributor may choose to distribute the Program in object code form under 76 | its own license agreement, provided that: 77 | 78 | a) it complies with the terms and conditions of this Agreement; and 79 | 80 | b) its license agreement: 81 | 82 | i) effectively disclaims on behalf of all Contributors all warranties and 83 | conditions, express and implied, including warranties or conditions of title 84 | and non-infringement, and implied warranties or conditions of merchantability 85 | and fitness for a particular purpose; 86 | 87 | ii) effectively excludes on behalf of all Contributors all liability for 88 | damages, including direct, indirect, special, incidental and consequential 89 | damages, such as lost profits; 90 | 91 | iii) states that any provisions which differ from this Agreement are offered 92 | by that Contributor alone and not by any other party; and 93 | 94 | iv) states that source code for the Program is available from such 95 | Contributor, and informs licensees how to obtain it in a reasonable manner on 96 | or through a medium customarily used for software exchange. 97 | 98 | When the Program is made available in source code form: 99 | 100 | a) it must be made available under this Agreement; and 101 | 102 | b) a copy of this Agreement must be included with each copy of the Program. 103 | 104 | Contributors may not remove or alter any copyright notices contained within 105 | the Program. 106 | 107 | Each Contributor must identify itself as the originator of its Contribution, 108 | if any, in a manner that reasonably allows subsequent Recipients to identify 109 | the originator of the Contribution. 110 | 111 | 4. COMMERCIAL DISTRIBUTION 112 | 113 | Commercial distributors of software may accept certain responsibilities with 114 | respect to end users, business partners and the like. While this license is 115 | intended to facilitate the commercial use of the Program, the Contributor who 116 | includes the Program in a commercial product offering should do so in a 117 | manner which does not create potential liability for other Contributors. 118 | Therefore, if a Contributor includes the Program in a commercial product 119 | offering, such Contributor ("Commercial Contributor") hereby agrees to defend 120 | and indemnify every other Contributor ("Indemnified Contributor") against any 121 | losses, damages and costs (collectively "Losses") arising from claims, 122 | lawsuits and other legal actions brought by a third party against the 123 | Indemnified Contributor to the extent caused by the acts or omissions of such 124 | Commercial Contributor in connection with its distribution of the Program in 125 | a commercial product offering. The obligations in this section do not apply 126 | to any claims or Losses relating to any actual or alleged intellectual 127 | property infringement. In order to qualify, an Indemnified Contributor must: 128 | a) promptly notify the Commercial Contributor in writing of such claim, and 129 | b) allow the Commercial Contributor tocontrol, and cooperate with the 130 | Commercial Contributor in, the defense and any related settlement 131 | negotiations. The Indemnified Contributor may participate in any such claim 132 | at its own expense. 133 | 134 | For example, a Contributor might include the Program in a commercial product 135 | offering, Product X. That Contributor is then a Commercial Contributor. If 136 | that Commercial Contributor then makes performance claims, or offers 137 | warranties related to Product X, those performance claims and warranties are 138 | such Commercial Contributor's responsibility alone. Under this section, the 139 | Commercial Contributor would have to defend claims against the other 140 | Contributors related to those performance claims and warranties, and if a 141 | court requires any other Contributor to pay any damages as a result, the 142 | Commercial Contributor must pay those damages. 143 | 144 | 5. NO WARRANTY 145 | 146 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON 147 | AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER 148 | EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR 149 | CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A 150 | PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the 151 | appropriateness of using and distributing the Program and assumes all risks 152 | associated with its exercise of rights under this Agreement , including but 153 | not limited to the risks and costs of program errors, compliance with 154 | applicable laws, damage to or loss of data, programs or equipment, and 155 | unavailability or interruption of operations. 156 | 157 | 6. DISCLAIMER OF LIABILITY 158 | 159 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY 160 | CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, 161 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION 162 | LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 163 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 164 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 165 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY 166 | OF SUCH DAMAGES. 167 | 168 | 7. GENERAL 169 | 170 | If any provision of this Agreement is invalid or unenforceable under 171 | applicable law, it shall not affect the validity or enforceability of the 172 | remainder of the terms of this Agreement, and without further action by the 173 | parties hereto, such provision shall be reformed to the minimum extent 174 | necessary to make such provision valid and enforceable. 175 | 176 | If Recipient institutes patent litigation against any entity (including a 177 | cross-claim or counterclaim in a lawsuit) alleging that the Program itself 178 | (excluding combinations of the Program with other software or hardware) 179 | infringes such Recipient's patent(s), then such Recipient's rights granted 180 | under Section 2(b) shall terminate as of the date such litigation is filed. 181 | 182 | All Recipient's rights under this Agreement shall terminate if it fails to 183 | comply with any of the material terms or conditions of this Agreement and 184 | does not cure such failure in a reasonable period of time after becoming 185 | aware of such noncompliance. If all Recipient's rights under this Agreement 186 | terminate, Recipient agrees to cease use and distribution of the Program as 187 | soon as reasonably practicable. However, Recipient's obligations under this 188 | Agreement and any licenses granted by Recipient relating to the Program shall 189 | continue and survive. 190 | 191 | Everyone is permitted to copy and distribute copies of this Agreement, but in 192 | order to avoid inconsistency the Agreement is copyrighted and may only be 193 | modified in the following manner. The Agreement Steward reserves the right to 194 | publish new versions (including revisions) of this Agreement from time to 195 | time. No one other than the Agreement Steward has the right to modify this 196 | Agreement. The Eclipse Foundation is the initial Agreement Steward. The 197 | Eclipse Foundation may assign the responsibility to serve as the Agreement 198 | Steward to a suitable separate entity. Each new version of the Agreement will 199 | be given a distinguishing version number. The Program (including 200 | Contributions) may always be distributed subject to the version of the 201 | Agreement under which it was received. In addition, after a new version of 202 | the Agreement is published, Contributor may elect to distribute the Program 203 | (including its Contributions) under the new version. Except as expressly 204 | stated in Sections 2(a) and 2(b) above, Recipient receives no rights or 205 | licenses to the intellectual property of any Contributor under this 206 | Agreement, whether expressly, by implication, estoppel or otherwise. All 207 | rights in the Program not expressly granted under this Agreement are 208 | reserved. 209 | 210 | This Agreement is governed by the laws of the State of New York and the 211 | intellectual property laws of the United States of America. No party to this 212 | Agreement will bring a legal action under this Agreement more than one year 213 | after the cause of action arose. Each party waives its rights to a jury trial 214 | in any resulting litigation. 215 | -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | 2 | [[https://clojars.org/threatgrid/ring-jwt-middleware][https://img.shields.io/clojars/v/threatgrid/ring-jwt-middleware.svg]] 3 | 4 | 5 | 6 | [[https://travis-ci.org/threatgrid/ring-jwt-middleware][https://travis-ci.org/threatgrid/ring-jwt-middleware.png?branch=master]] 7 | 8 | * ring-jwt-middleware 9 | 10 | A simple middleware to authenticate users using JWT (JSON Web Tokens) 11 | currently, only RS256 is supported. 12 | 13 | ** Features 14 | 15 | - RS256 signing 16 | - uses IANA "JSON Web Token Claims" 17 | - JWT lifetime & Expiration support 18 | - custom additional validation through user-provided functions 19 | - custom revocation check through user-provided functions 20 | 21 | ** Usage 22 | 23 | *** Quickly 24 | 25 | If you do not want to use a log middleware: 26 | 27 | 28 | #+begin_src clojure 29 | (defn my-handler 30 | [request] 31 | ,,,) 32 | 33 | (def jwt-middleware (wrap-jwt-auth-fn {:pubkey-path jwt-cert-path})) 34 | 35 | (jwt-middleware my-handler) 36 | #+end_src 37 | 38 | If you want to use a log middleware that will log both user identites 39 | derived from JWT and response statuses (and other response stats): 40 | 41 | #+begin_src clojure 42 | (defn my-handler 43 | [request] 44 | ,,,) 45 | 46 | (defn wrap-logs 47 | "A middleware logging the requests" 48 | [handler] 49 | (fn [request] 50 | (let [user-identity (:identity request) 51 | response (handler request)] 52 | (log/info (pr-str {:user-identity user-identity 53 | :uri (:uri request) 54 | :status (:status response)})) 55 | response))) 56 | 57 | (def jwt-middleware 58 | (wrap-jwt-auth-with-in-between-middleware-fn 59 | {:pubkey-path jwt-cert-path} 60 | wrap-logs)) 61 | 62 | (jwt-middleware my-handler) 63 | #+end_src 64 | 65 | *** Authentication 66 | 67 | For Authentication only, and handle the authorization entirely yourself: 68 | 69 | #+begin_src clojure 70 | (defn my-handler 71 | [request] 72 | ,,,) 73 | 74 | (let [wrap-authentication 75 | (mk-wrap-authentication {:pubkey-path jwt-cert-path})] 76 | (wrap-authentication my-handler)) 77 | #+end_src 78 | 79 | At this step the ~request~ passed to ~my-handler~ will have some of the following keys added: 80 | 81 | - ~jwt~ => the claims of the JWT 82 | - ~identity~ => the object representing the user identity constructed using JWT claims 83 | - ~jwt-error~ => will contain an error object if the something went wrong with the JWT 84 | 85 | The ~wrap-authentication~ will not take any decision about authorization access. 86 | This lib also provides another helper to build another middleware handling 87 | authorization. 88 | 89 | You can inject your own authorization rules, via: 90 | 91 | #+begin_src clojure 92 | (let [wrap-authentication (mk-wrap-authentication 93 | {:pubkey-path "/etc/secret/jwt.pub" 94 | :is-revoked-fn my-revocation-check-fn 95 | :jwt-check-fn my-jwt-checks}) 96 | wrap-authorization (mk-wrap-authorization 97 | {:error-handler my-error-handler})] 98 | (wrap-authentication (wrap-authorization my-handler))) 99 | #+end_src 100 | 101 | *** Options 102 | 103 | Notice you could add the following keys in the configuration passed to ~mk-wrap-authentication~, ~mk-wrap-authorization~ and ~wrap-jwt-auth-fn:~ 104 | 105 | #+begin_src clojure 106 | (s/defschema Config* 107 | (st/merge 108 | {:allow-unauthenticated-access? 109 | (describe s/Bool 110 | "Set this to true to allow unauthenticated requests") 111 | :current-epoch 112 | (describe (s/=> s/Num) 113 | "A function returning the current time in epoch format") 114 | :is-revoked-fn 115 | (describe (s/=> s/Bool JWTClaims) 116 | "A function that take a JWT and return true if it is revoked") 117 | :jwt-max-lifetime-in-sec 118 | (describe s/Num 119 | "Maximal number of second a JWT does not expires") 120 | :error-handler 121 | (describe (s/=> s/Any) 122 | "A function that given a JWTError returns a ring response.") 123 | 124 | :default-allowed-clock-skew-in-seconds 125 | (describe s/Num 126 | "When the JWT does not contain any nbf claim, the number of seconds to remove from iat claim. Default 60.")} 127 | (st/optional-keys 128 | {:post-jwt-format-fn 129 | (describe (s/=> s/Any JWTClaims) 130 | "A function taking the JWT claims and building an Identity object suitable for your needs") 131 | :post-jwt-format-with-request-fn 132 | (describe (s/=> s/Any JWTClaims) 133 | "A function taking the JWT claims and the request, and building an Identity object suitable for your needs") 134 | :pubkey-fn (describe (s/=> s/Any s/Str) 135 | "A function returning a public key (takes precedence over pubkey-path)") 136 | :pubkey-fn-arg-fn (describe (s/=> s/Any s/Any) 137 | "A function that will be applied to the argument (the raw JWT) of `pubkey-fn`") 138 | :post-jwt-format-fn-arg-fn (describe (s/=> s/Any s/Any) 139 | "A function that will be applied to the argument (the raw JWT) of `post-jwt-format-fn` or `post-jwt-format-with-request-fn`") 140 | :pubkey-path (describe s/Str 141 | "The path to find the public key that will be used to check the JWT signature") 142 | :jwt-check-fn 143 | (describe (s/=> s/Bool JWT JWTClaims) 144 | (str "A function that take a JWT, claims and return a sequence of string containing errors." 145 | "The check is considered successful if this function returns nil, or a sequence containing only nil values."))}))) 146 | 147 | (s/defschema Config 148 | "Initialized internal Configuration" 149 | (s/constrained 150 | Config* 151 | (fn [{:keys [post-jwt-format-fn post-jwt-format-with-request-fn]}] 152 | (or post-jwt-format-fn 153 | post-jwt-format-with-request-fn)) 154 | "One of `post-jwt-format-fn` or `post-jwt-format-with-request-fn` is required. `post-jwt-format-with-request-fn` has precedence.")) 155 | #+end_src 156 | 157 | By default if no JWT authorization header is found the request is terminated with 158 | =unauthorized= HTTP response. 159 | 160 | By default the ~:identity~ contains the ~"sub"~ field of the JWT. But you can 161 | use more complex transformation. For example, there is a =jwt->oauth-ids= 162 | function in the code that could be used to handle JWT generated from an OAuth2 163 | provider. 164 | 165 | *** JWT Format 166 | 167 | Currently this middleware only supports JWT using claims registered in the IANA "JSON Web Token Claims", 168 | which means you need to generate JWT using most of the claims described here: https://tools.ietf.org/html/rfc7519#section-4 169 | namely =jti=, =exp=, =iat=, =nbf=, =sub=: 170 | 171 | | Claim | Description | Format | 172 | |-------+--------------------------------------------------------------------+--------| 173 | | =:exp= | Expiration time: https://tools.ietf.org/html/rfc7519#section-4.1.4 | Long | 174 | | =:iat= | Issued At: https://tools.ietf.org/html/rfc7519#section-4.1.6 | Long | 175 | | =:jti= | JWT ID: https://tools.ietf.org/html/rfc7519#section-4.1.7 | String | 176 | | =:nbf= | Not Before: https://tools.ietf.org/html/rfc7519#section-4.1.5 | Long | 177 | | =:sub= | Subject: https://tools.ietf.org/html/rfc7519#section-4.1.2 | String | 178 | 179 | here is a sample token: 180 | 181 | #+BEGIN_SRC clojure 182 | {:jti "r3e03ac6e-8d09-4d5e-8598-30e51a26cd2a" 183 | :exp 1499419023 184 | :iat 1498814223 185 | :nbf 1498813923 186 | :sub "f0010924-e1bc-4b03-b600-89c6cf52757c" 187 | 188 | :email "foo@bar.com" 189 | "http://example.com/claim/user/name" "john doe"} 190 | #+END_SRC 191 | 192 | ** Generating Certs and a Token 193 | 194 | A simple script is available to generate keys for signing the tokens: 195 | => ./resources/cert/gen_cert.sh= 196 | some dummy ones are already available for easy testing. 197 | 198 | - use =ring-jwt-middleware.core-test/make-jwt= to generate a sample token from a map 199 | 200 | ** License 201 | 202 | Copyright © 2015-2021 Cisco Systems 203 | Eclipse Public License v1.0 204 | -------------------------------------------------------------------------------- /doc/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction to ring-jwt-middleware 2 | 3 | TODO: write [great documentation](http://jacobian.org/writing/what-to-write/) 4 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject threatgrid/ring-jwt-middleware "1.1.8-SNAPSHOT" 2 | :description "A simple middleware to deal with JWT Authentication" 3 | :pedantic? :abort 4 | :license {:name "Eclipse Public License - v 1.0" 5 | :url "http://www.eclipse.org/legal/epl-v10.html" 6 | :distribution :repo} 7 | :url "http://github.com/threatgrid/ring-jwt-middleware" 8 | :deploy-repositories [["releases" {:url "https://clojars.org/repo" :creds :gpg}] 9 | ["snapshots" {:url "https://clojars.org/repo" :creds :gpg}]] 10 | :dependencies [[org.clojure/clojure "1.10.3"] 11 | [threatgrid/clj-jwt "0.3.1"] 12 | [org.clojure/tools.logging "1.0.0"] 13 | [metosin/ring-http-response "0.9.1"] 14 | [prismatic/schema "1.1.12"] 15 | [metosin/schema-tools "0.12.3"]] 16 | :profiles {:dev {:pedantic? :warn 17 | :dependencies [[clojure.java-time "0.3.3"]]}}) 18 | -------------------------------------------------------------------------------- /resources/cert/gen_cert.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | keybasename=jwt-key 3 | 4 | for i in `seq 1 3`; do 5 | 6 | openssl genrsa -out $keybasename-$i.key -passout pass:clojure 2048 7 | openssl req -passin pass:clojure -out ring-jwt-middleware.csr -key $keybasename-$i.key -new -sha256 -subjj"/C=FR/ST=France/L=Nice/O=Cisco/OU=CTR/CN=cisco.com/emailAddress=dev.null@dev.null" 8 | openssl rsa -passin pass:clojure -in $keybasename-$i.key -pubout -out $keybasename-$i.pub 9 | 10 | done 11 | -------------------------------------------------------------------------------- /resources/cert/jwt-key-1.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEA1WMtgelr94/kaoaqyhjkZPL5bUV3/J31lP64uKN9ossd2WoJ 3 | BUyHW3NpfjiPKJ8c+Sd0ed8pbGUevjdEMKXmMEdh+j2EWhvmXCiYrk6Aev2VU2L5 4 | VEfPXlHCNdK5JRaKbrd9QwfECB4nfyBm1dZUmeOmyD434jjKCvjKPdzh/Pnu9DtW 5 | RhZUOkNmtudTO+YxE/yH44iot1IxrECehY5qUuz33xuSUOIBLoGXl7hi6SkWTmJU 6 | Kz0dK3ZZ3cFAtkEnwOvLt2gF3f3m1BiAN2x2L07WbrYX9qnLIIFKdKuQeGFQatyp 7 | lhhgDxIqIdXsyunmU9mgrKlMYYgHbekqeIM+uwIDAQABAoIBACJTF8jj8VgbL1Tr 8 | YKGtq1+JrhGjsQ9ToWNcLf6VXepH3f9RDS5rBwLw57FEC9Mu9QwnCpdoDFPBWFak 9 | sTVaGlL9yIbmSlnsTvvYE+dF/WjLTa/iJdUmz/aOFD1wLhQHMjFpFp8nqqLbpuLO 10 | JXyUQ3uVoQho6bhcBjQJnQ6ibz+v8Q2n/RyswFCalYnkj13ddlsDS0jDLVKV2H3x 11 | 8wL26wiP16WWbVHC/NFSnxutaZHKSW4YTarq9Hm4gadXfr9P/l17/tZUFOAoP/R3 12 | 9nhpcmUFx7/QXyFt5uoa/Tk6H45ac0221eBoCjn5TKnIYu8XuC6SqMTw4TRc/3gZ 13 | 8fnwuCECgYEA/Wv3SqRW+Ke4z8KRMOSMcBRUAFszAoqeHw2RSNZyoFwECubF4AMv 14 | /0pFVZl5ZisdhyyUapeiFmSL/avvcSf+GLSFIQnQxTJALYelzeQfOm9MJj4KPTxo 15 | fxDbBmZyz077jhKkb6HG/1HTg+8vFvAws/Mvi0Kq7qBg2sMyjSlyHnkCgYEA147x 16 | XVrkAJKFADzMBplJCpEs6uaLaVM83JG05ZFWmriHGvo6IvnPw+llrO6Lyn6FrVTz 17 | 2XiWO6aYGgyWwBdykNV2WKO1lAHS/mSWkLUecXRWf0rYqM4Lq4t/0QrKDMUa7L16 18 | 0pKBM56cLM0SP+PjB/iQQvrtjuxfkMlfrTF46dMCgYBzz+ldRSkxzYRZ/fLYQzoi 19 | 5kdVOlhfavXD/zFL9iTAQAzg5oG7U/mVU13INrESDdYatjbFV7KNRTnsnax5K+ul 20 | YqoMZS3xUHuf0wPkycGztLU6LNQFFBw1JYDStfL15oRzcvWOxoHooH5nJuGAPBgl 21 | xJcPr1HqZ4SbtOTfOfebEQKBgQDIMr8iiB7IFUynOs/2tPiscsa356TFDoSxCjZr 22 | G//GNOlt+cZy43a+Ko+++9IjID0BDaxoZuGIxyHu15BPGbfSoh6HFoy2yLbwg/V+ 23 | smhy0KzhDl+I78zQ5v+v44hiMdHe2+Atn9mVWpML3O49Hmal6Yn6W1i06/2Z2B0f 24 | GpqE9wKBgGn8k02XQJAoq4yE1ORIeJVdA+c+eySUctEn/45Qpn5W57Dq6/mYm19K 25 | 4MKboJKvNqL+RCmQu6ya+4asX1NQiwTCXQ/GSSCMr7xcQ8a6h1l3HOQjec41nVA8 26 | /VrS73ZzRLN1jcHwd8Dm4O/hapbat9zYdiwy+V4gGE21KYawJWr0 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /resources/cert/jwt-key-1.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1WMtgelr94/kaoaqyhjk 3 | ZPL5bUV3/J31lP64uKN9ossd2WoJBUyHW3NpfjiPKJ8c+Sd0ed8pbGUevjdEMKXm 4 | MEdh+j2EWhvmXCiYrk6Aev2VU2L5VEfPXlHCNdK5JRaKbrd9QwfECB4nfyBm1dZU 5 | meOmyD434jjKCvjKPdzh/Pnu9DtWRhZUOkNmtudTO+YxE/yH44iot1IxrECehY5q 6 | Uuz33xuSUOIBLoGXl7hi6SkWTmJUKz0dK3ZZ3cFAtkEnwOvLt2gF3f3m1BiAN2x2 7 | L07WbrYX9qnLIIFKdKuQeGFQatyplhhgDxIqIdXsyunmU9mgrKlMYYgHbekqeIM+ 8 | uwIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /resources/cert/jwt-key-2.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAvLTZL26vVPPOpbioRRE2TIiX7P9/a4T0f4zSYiRBxvysbLoW 3 | bnJN8ixD43kyz8MgnpybXm/Wtsx6RLxjI+RKr+ZTQzIvL91HlMDxZNdgwIf+VcDu 4 | IUl8Ru164lWaPg5N4rLyyKqzk2R7W5h2O4v2vb1x73Q1eLfd9+MaQsUGRpIlKrVw 5 | Nqk0sp4T/Bc8jL6Bbj0RhBb8/Ud9JaFydHh5IJk8FPc/pgH8KI06dAlR7DhpYxBN 6 | D84UNkqsJBhpodEU30In+bL8mINJtWasmRDjO172V0CLCv3Zq+1RvZIXbLpL/8ID 7 | HNmR/pd4i6kDnjN4ABnYNtWT/iSUXeKiPKlY5wIDAQABAoIBAQCqmo3n9heJpEpl 8 | RQbMOn1uv+cqmusl17P8ROJHXCQjtHhma+6vt6OEmERgOUiY0P6Cp29H97CU31SD 9 | JBY1csWbNY59J7fFfDak47LZymZsagaknKItcfRrY3Q/f8jM5b3AgKxCgyGK1cM4 10 | 0iPCPg27CLUjGVrmYsTIwYcSanH8BcMM9vSbW01nOt4cEx33tVPAJzh0MLi+s6bg 11 | EZmz6MVRr/TPhDyssmszj2ng6vs1mig95CvRUoxg4Y5X2Yrc2FtSiuzc4RnIfUWQ 12 | +pdq1o995XE+xKynOv1IJRxMgInQFaE6Tjwb2GXocqmNaxNjpthexVB7Y88RoNsL 13 | ono2Q3UBAoGBAOFRhUy3FD4uyezuf2slDATD1xvXc/F5h1T20W4t0Nam+hMggK7Z 14 | 8ve4I0FcZHcf0sC5Jao27gzZNvhosOL9HcVuGNR2zR0wxBdBk5J0eaeYDLKeeulV 15 | rYvGyxBWn7aq1P9YTOMKo/Bz77AuuQIFBveF3lJXXVIPEBbwzJUCmpPBAoGBANZn 16 | DZVxHd1U7lJIHZg0U9LFMN+L4Y+ktP6BwAxo3Sdk9GnT7IchzVOEJezARrTlGv+N 17 | Y8EpyFF0/ORrg/8BzkMQURZvYsw9kpBzs/T80sdxxI8J8v4cUfPbHeX4PSQ9RUpf 18 | gw/eRdWpn6APNunmjl10qzlpRakUWbrsq0nXuHanAoGAbnz0Odc5BPkaqaWWM+s5 19 | xohmTk/Lmij29PIHZtjupKPC02hH0fYsT88q8p0k4slnJnxj/ODswL4vV4a23sgy 20 | NiMz59PN5zeHoMPWYqXdFhLYfyp5qTwLWxSDdSKVNRT2V7RsF5WbKIkhiyqOLHBc 21 | pzos+AHBUED0qdsw5w0c0cECgYEAh8XO5loxzV3hZh3hD+fjRVA2uPn+J1bof1tv 22 | YOxvUGkwFmxsFs8jFcZTte+1VwzW4gqJp2NsRZlOJOCpQC3GjtWgZBK5gBll3TBI 23 | P9ZzLHYxQVvNk3ofs8uIzX88SNy8KnL9rmjV1I6MVXINFmY4tZoSbFLsGDyY7jS9 24 | 7A8983UCgYBXT27WM8z+zBzAmXiMjKZKHRUjk7Y89H/vNefVuBlYWAxhGTVAYxiU 25 | KAEJRu7G7VogmPy5/w4M/73/dVhSpd4j4K+sNRRs/9SnlM+ifcrdau+Sw88PiDMu 26 | Vs6baevKHuXrURz6FTl1k3OQsMU6cpoUAR59lHqvt/JfJe09Oj6t4g== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /resources/cert/jwt-key-2.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvLTZL26vVPPOpbioRRE2 3 | TIiX7P9/a4T0f4zSYiRBxvysbLoWbnJN8ixD43kyz8MgnpybXm/Wtsx6RLxjI+RK 4 | r+ZTQzIvL91HlMDxZNdgwIf+VcDuIUl8Ru164lWaPg5N4rLyyKqzk2R7W5h2O4v2 5 | vb1x73Q1eLfd9+MaQsUGRpIlKrVwNqk0sp4T/Bc8jL6Bbj0RhBb8/Ud9JaFydHh5 6 | IJk8FPc/pgH8KI06dAlR7DhpYxBND84UNkqsJBhpodEU30In+bL8mINJtWasmRDj 7 | O172V0CLCv3Zq+1RvZIXbLpL/8IDHNmR/pd4i6kDnjN4ABnYNtWT/iSUXeKiPKlY 8 | 5wIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /resources/cert/jwt-key-3.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEA0dhYyy/pU+HsttxGHWlG0a+i4VGSDRfPiJJzdyP/TODh9iR9 3 | sqrHhfhbZALNxsYPIVIYlOSURShEMeMJUtVggCalsNGYsRfQth2ZMvdfKIhP70x/ 4 | CrMExh6sujX6vqWOtnb3sGwXE6to8/DxOSzmNZ0ewtulaRm/OZDpjPqvkBIr1/hj 5 | pLpr73d20VeOUWA30XxqAYAPBsO2LHf+TRmkdShPFHDY6oVo2l3S3esWETIYXVA1 6 | i/9vo5yhEz3O7ppqVtXlPna2rXuB0qflxd3wqU05BJq07n9rEgiX8uafMSjg2F6I 7 | Fzo2QXlTgC21/+ppkpbD/cP39NyNtRnGHBUBCwIDAQABAoIBAHajVjAp+kSOaoJH 8 | ruxZ5MwP/Ipez6/PUmnoQpeNJn2UdqvunZIxs3QuoZy9aRGEUHGKNJOZV6pxxXxe 9 | 25qVQGWzc+Gv3h/hUwJjYYXLDWWhC3BDU1/9LinElesBMa4vc6v9PrPjqHhaZI/m 10 | HLsyg0S0e/u2qqHtCIvHPgOSh0dvuHMQiMfqJgOEnCBgBo7JkgRSj/q07YwzHHtc 11 | CoejyKEnMQ6sjoRMcb9SgLYhKfk82qmaERbZQalKfjzHGW89ujfmDKq0hoHOE42c 12 | I45+xfDWvrySfP/ZRoCYPFOlwld2V7QyYiYpcFkTobqaHb8mHcNZBFICnGIxZlbI 13 | KMpfjkECgYEA6/sYzZ2oOa4R5OiAWcZz7d0xVcZrhOXQ2EQuf2BVqgKtMF9SgF9t 14 | MUGG8kZWHQKSirnZBbE2a603aKD471AdNu3/At2ALCzCOQIbubXcRQL9isWl8DjL 15 | 5xuzQnp7/yOdRnvP6Nv77bAyydL+8FIXvV74kxCrhJjT6oqGPxBu+jkCgYEA46Wl 16 | 78n5MwSMygFoozyEM/hL7wXYbX05Obo9kj7GkwYQGEhpZje4sKz1DncDRVF1DGtb 17 | ljcTdzDDaPtPlNFRqagD7a1QvUFubztkyBT3DhEtm3w38VO9ljCLlB+3Kl3olDx7 18 | 5Ul6pxFtfIGEf83pBkmgEp2CQWoIhezfpG8AJWMCgYEAs7SAavUIywQYdG3qcHOE 19 | jTvI+j0kXAx3QnqJvk6qbdGj+hZdSY1iJgR0s8OEiIsQ9bwZ3Q/bFZzPeUo8yGIG 20 | HXKYpKHxnbJoJenJG1+24ocodamWeJ2ICfM6TAHis6V3e9mFtxrve06XHsMjife6 21 | RGgyfCxRsaSAnTfoi9YD7WECgYEAuCXJQ12ofgKSON5G3LUZZEjYc/PQjVEct3vW 22 | TrUFQO1Im7wHtq4gcpqWaSsQtEQeYPt/TNYkougJSqTwTTu9yXWmJ99pTo7dXJE5 23 | BzkeWHzA3ePwBQFufU2ruxikvFrWxwLeXi3AT+EjJfJHUnMoqvNnz2cmdO/nER76 24 | EjPQdG0CgYA6iRO6rP2SXrK8quaPc6HMzFBlPeNXm1bz+lNbzL82JVlMWeBxGvdl 25 | ma8YiYmH+1kfJqkcgE+92Nfm3HwzylXI/JL958VSqVoETXGvwgtoKZC5vKl+Vh62 26 | E+HMp6IH82bmTPSlx23F/0g0Qfb41iLrEfqyMCI7g8ATGRmhhpMmnw== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /resources/cert/jwt-key-3.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0dhYyy/pU+HsttxGHWlG 3 | 0a+i4VGSDRfPiJJzdyP/TODh9iR9sqrHhfhbZALNxsYPIVIYlOSURShEMeMJUtVg 4 | gCalsNGYsRfQth2ZMvdfKIhP70x/CrMExh6sujX6vqWOtnb3sGwXE6to8/DxOSzm 5 | NZ0ewtulaRm/OZDpjPqvkBIr1/hjpLpr73d20VeOUWA30XxqAYAPBsO2LHf+TRmk 6 | dShPFHDY6oVo2l3S3esWETIYXVA1i/9vo5yhEz3O7ppqVtXlPna2rXuB0qflxd3w 7 | qU05BJq07n9rEgiX8uafMSjg2F6IFzo2QXlTgC21/+ppkpbD/cP39NyNtRnGHBUB 8 | CwIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /resources/cert/ring-jwt-middleware.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIICiTCCAXECAQAwRDELMAkGA1UEBhMCVVMxDDAKBgNVBAgTA05ZQzEOMAwGA1UE 3 | BxQFTllWf0MxFzAVBgNVBAoUDkNpc2Nvf39/f39/f39/MIIBIjANBgkqhkiG9w0B 4 | AQEFAAOCAQ8AMIIBCgKCAQEA7XcE2sIoRXrWvhGKCEbkUF0K7zQ34emC5QoDE8gO 5 | Cu+XszY0c8/q/KEi8PdjeNy+wxMur4DpBvH4QmtzGdrqhjQF+sl6E7jvHA9TU/rJ 6 | 0CpkGd4v3sIU6Yyjq83/ixGqG+0Ou3LM0qbczu4ih/ch7Phhg4xILDa1paBpbr16 7 | 3R8RViy/zKSBY2r2GqPU5gIt7d+ZCoI5X000kboVHCSe6hItun/nrcYir+ctq5WN 8 | qPrPvf1I0qmIkkGagrk6opRZapDJd5T4cYIqC9ErJDPrDho9yTqm+Vk3M9kOj6hD 9 | 11wKaA+O0if4ddxkjk7lbvhdQt7+a88mkNZn0wag/jpyrwIDAQABoAAwDQYJKoZI 10 | hvcNAQELBQADggEBAB35ZuGMqVARJhFxlAT7/6eK7MxNkz+jiO8hNHg2g883a6cx 11 | 9mkNXTdZot2BqCU5XpXojBFZRG/ph3xXGzJaD8Yn9TwOl3z+VQyV2T0I+IWL+0hg 12 | nlKb9OE5eml2Tk1jmheh4T0Wjb4d6fu3TRGNFX40qy4ZHYj7SXfIE9xtTxLmtjnn 13 | Xb6zRVAoD6krnHVTsxd+GIE04RNYAswrGPa/iAEJQxcmXpD8q2ftuEQ3tpxL2pEp 14 | 481G1KJ7LOALtrkq1P2y2Std8xbIFuDdnWx4GdWqcMmlteF0l1Sl1WbzBeujNKog 15 | OdSp7/8CRxL7tJlTQD+S3cUrkuFMT+fcD4tObzI= 16 | -----END CERTIFICATE REQUEST----- 17 | -------------------------------------------------------------------------------- /src/ring_jwt_middleware/config.clj: -------------------------------------------------------------------------------- 1 | (ns ring-jwt-middleware.config 2 | (:require [clojure.string :as string] 3 | [clojure.tools.logging :as log] 4 | [ring-jwt-middleware.schemas :refer [Config JWTClaims UserConfig]] 5 | [ring.util.http-response :as resp] 6 | [schema.core :as s])) 7 | 8 | (s/defn current-millis! :- s/Num 9 | "This intermediate function is useful to use with-redefs during external tests" 10 | [] 11 | (System/currentTimeMillis)) 12 | 13 | (s/defn current-epoch! :- s/Num 14 | "Returns the current time in epoch" 15 | [] 16 | (quot (current-millis!) 1000)) 17 | 18 | (defn default-error-handler 19 | "Return an `unauthorized` HTTP response and log the error along debug infos" 20 | [{:keys [error error_description] :as jwt-error}] 21 | (log/infof "%s: %s %s" error error_description (dissoc jwt-error :error :error_description :raw_jwt)) 22 | (resp/unauthorized (dissoc jwt-error :raw_jwt))) 23 | 24 | (def default-jwt-lifetime-in-sec 25 | "Default JWT lifetime is 24h" 26 | 86400) 27 | 28 | (def no-revocation-strategy 29 | "The default function used for `:is-revoked-fn` configuration" 30 | (constantly false)) 31 | 32 | (s/defn jwt->user-id :- s/Str 33 | "can be used as post-jwt-format-fn" 34 | [jwt :- JWTClaims] 35 | (:sub jwt)) 36 | 37 | (s/defn jwt->oauth-ids 38 | "can be used as post-jwt-format-fn 39 | 40 | This is an example function that given a JWT whose claims looks like: 41 | 42 | - :sub 43 | - \"/scopes\" 44 | - \"/org/id\" 45 | - \"/oauth/client/id\" 46 | 47 | It is a generic format about what an access-token should provide: 48 | 49 | - user-id, client-id, scopes 50 | - org-id 51 | 52 | mainly transform a list of /foo/bar/baz value into a deep nested map. 53 | For example: 54 | 55 | (sut/jwt->oauth-ids 56 | \"http://example.com/claims\" 57 | {:sub \"user-id\" 58 | \"http://example.com/claims/scopes\" [\"scope1\" \"scope2\"] 59 | \"http://example.com/claims/user/id\" \"user-id\" 60 | \"http://example.com/claims/user/name\" \"John Doe\" 61 | \"http://example.com/claims/user/email\" \"john.doe@dev.null\" 62 | \"http://example.com/claims/user/idp/id\" \"iroh\" 63 | \"http://example.com/claims/user/idp/name\" \"Visibility\" 64 | \"http://example.com/claims/org/id\" \"org-id\" 65 | \"http://example.com/claims/org/name\" \"ACME Inc.\" 66 | \"http://example.com/claims/oauth/client/id\" \"client-id\" 67 | \"http://example.com/claims/oauth/kind\" \"code\"}) 68 | 69 | => {:user {:idp {:name \"Visibility\" 70 | :id \"iroh\"}, 71 | :name \"John Doe\", 72 | :email \"john.doe@dev.null\", 73 | :id \"user-id\"} 74 | :oauth {:kind \"code\" 75 | :client {:id \"client-id\"}}, 76 | :org {:name \"ACME Inc.\" 77 | :id \"org-id\"}, 78 | :scopes #{\"scope1\" \"scope2\"}} 79 | " 80 | [prefix :- s/Str 81 | jwt :- JWTClaims] 82 | (let [n (+ 1 (count prefix)) 83 | update-if-contains? (fn [m k f] 84 | (if (contains? m k) 85 | (update m k f) 86 | m)) 87 | keywordize #(map keyword %) 88 | str-to-path (fn [k] 89 | (-> k ;; the key of the jwt map that starts with prefix 90 | (subs n) ;; remove the prefix 91 | (string/split #"/") ;; split on / 92 | ;; finally keywordize all elements 93 | keywordize)) 94 | tmp (->> jwt 95 | (map (fn [[k v]] 96 | (when (and (string? k) (string/starts-with? k prefix)) 97 | [(str-to-path k) v]))) 98 | (remove nil?) ;; remove key not starting by prefix 99 | (reduce (fn [acc [kl v]] (assoc-in acc kl v)) {}) ;; construct the hash-map 100 | )] 101 | (-> tmp 102 | (assoc-in [:user :id] (:sub jwt)) ;; :sub overwrite any :user :id 103 | (update-if-contains? :scopes set) ;; and scopes should be a set, not alist 104 | ))) 105 | 106 | (def default-config 107 | {:allow-unauthenticated-access? false 108 | :default-allowed-clock-skew-in-seconds 60 109 | :current-epoch current-epoch! 110 | :is-revoked-fn no-revocation-strategy 111 | :jwt-max-lifetime-in-sec default-jwt-lifetime-in-sec 112 | :post-jwt-format-fn jwt->user-id 113 | :post-jwt-format-fn-arg-fn :claims 114 | :pubkey-fn-arg-fn :claims 115 | :error-handler default-error-handler}) 116 | 117 | (defn conf-valid? 118 | [{:keys [pubkey-path pubkey-fn] :as conf}] 119 | (s/validate Config conf) 120 | (assert (or pubkey-path pubkey-fn) 121 | "The configuration should provide at least one of `pubkey-path` or `pukey-fn`")) 122 | 123 | (s/defn ->config :- Config 124 | [user-config :- UserConfig] 125 | (let [config (into default-config user-config)] 126 | (conf-valid? config) 127 | config)) 128 | -------------------------------------------------------------------------------- /src/ring_jwt_middleware/core.clj: -------------------------------------------------------------------------------- 1 | (ns ring-jwt-middleware.core 2 | (:require [clj-jwt.core :refer [str->jwt verify]] 3 | [clj-jwt.key :refer [public-key]] 4 | [clojure.set :as set] 5 | [clojure.string :as string] 6 | [ring-jwt-middleware.config :refer [->config]] 7 | [ring-jwt-middleware.result 8 | :refer 9 | [->err ->pure <-result let-either result-of]] 10 | [ring-jwt-middleware.schemas :refer [Config JWTClaims JWTDecoded UserConfig]] 11 | [ring.util.http-response :as resp] 12 | [schema.core :as s])) 13 | 14 | (s/defn get-jwt :- (result-of s/Str) 15 | "get the JWT from a ring request" 16 | [req] 17 | (if-let [raw-jwt (some->> (get-in req [:headers "authorization"]) 18 | (re-seq #"^Bearer\s+(.*)$") 19 | first 20 | second)] 21 | (->pure raw-jwt) 22 | (->err :no_jwt "No JWT found in HTTP headers" {}))) 23 | 24 | (s/defn decode :- (result-of {:jwt JWTDecoded}) 25 | "Given a JWT return an Auth hash-map" 26 | [token :- s/Str 27 | pubkey-fn :- (s/=> s/Any) 28 | pubkey-fn-arg-fn :- (s/=> s/Any)] 29 | (try 30 | (let [jwt (str->jwt token)] 31 | (if-let [pubkey (pubkey-fn (pubkey-fn-arg-fn jwt))] 32 | (if (verify jwt :RS256 pubkey) 33 | (->pure {:jwt (select-keys jwt [:header :claims])}) 34 | (->err :jwt_invalid_signature "Invalid Signature" {:level :warn 35 | :jwt jwt 36 | :token token})) 37 | (->err :jwt_public_key_not_found 38 | (str "Cannot retrieve a key for your JWT." 39 | " One common reason would be that it has the wrong `iss` claim") 40 | {:jwt jwt 41 | :level :warn 42 | :token token}))) 43 | (catch Exception e 44 | (->err :jwt_decode_failed_exception 45 | "JWT decode failed" 46 | {:exception_message (.getMessage e) 47 | :token token 48 | :level :warn 49 | :exception e})))) 50 | 51 | (s/defn hr-duration :- s/Str 52 | "Given a duration in ms, 53 | return a human readable string" 54 | [t :- s/Num] 55 | (let [second 1000 56 | minute (* 60 second) 57 | hour (* 60 minute) 58 | day (* 24 hour) 59 | year (* 365 day) 60 | nb-years (quot t year) 61 | nb-days (quot (rem t year) day) 62 | nb-hours (quot (rem t day) hour) 63 | nb-minutes (quot (rem t hour) minute) 64 | nb-seconds (quot (rem t minute) second) 65 | nb-ms (rem t second)] 66 | (->> (vector 67 | (when (pos? nb-years) 68 | (str nb-years " year" (when (> nb-years 1) "s"))) 69 | (when (pos? nb-days) 70 | (str nb-days " day" (when (> nb-days 1) "s"))) 71 | (when (pos? nb-hours) 72 | (str nb-hours "h")) 73 | (when (pos? nb-minutes) 74 | (str nb-minutes "min")) 75 | (when (pos? nb-seconds) 76 | (str nb-seconds "s")) 77 | (when (pos? nb-ms) 78 | (str nb-ms "ms"))) 79 | (remove nil?) 80 | (string/join " ")))) 81 | 82 | (s/defn check-jwt-expiry :- (result-of s/Keyword) 83 | "Return a result with some error if the JWT do not respect time-related restrictions." 84 | [{:keys [jwt-max-lifetime-in-sec current-epoch default-allowed-clock-skew-in-seconds]} :- Config 85 | jwt :- JWTClaims] 86 | (let [required-fields #{:exp :iat} 87 | jwt-keys (set (keys jwt)) 88 | iat (:iat jwt 0) 89 | exp (:exp jwt 0) 90 | nbf (or (:nbf jwt) 91 | (- iat default-allowed-clock-skew-in-seconds))] 92 | (if (set/subset? required-fields jwt-keys) 93 | (let [now (current-epoch) 94 | expired-secs (- now (+ iat jwt-max-lifetime-in-sec)) 95 | before-secs (- nbf now) 96 | expired-lifetime-secs (- now exp) 97 | err-metas {:jwt jwt :now now}] 98 | (cond 99 | (pos? before-secs) 100 | (->err :jwt_valid_in_future 101 | (format "This JWT will be valid in %s" 102 | (hr-duration (* 1000 before-secs))) 103 | err-metas) 104 | (pos? expired-secs) 105 | (->err :jwt_expired_via_max_jwt_lifetime 106 | (format (str "This JWT has expired %s ago (we don't allow JWT older than %s;" 107 | " we only check creation date and not maximal expiration date)") 108 | (hr-duration (* 1000 expired-secs)) 109 | (hr-duration (* 1000 jwt-max-lifetime-in-sec))) 110 | err-metas) 111 | (pos? expired-lifetime-secs) 112 | (->err :jwt_expired 113 | (format "This JWT max lifetime has expired %s ago" 114 | (hr-duration (* 1000 expired-lifetime-secs))) 115 | err-metas) 116 | :else (->pure :ok))) 117 | (->err :jwt_missing_field 118 | (format "This JWT doesn't contain the following fields %s" 119 | (pr-str (set/difference required-fields jwt-keys))) 120 | {:jwt jwt})))) 121 | 122 | (s/defn validate-jwt :- (result-of s/Keyword) 123 | "Run both expiration and user checks, 124 | return a vec of errors or nothing" 125 | ([{:keys [jwt-check-fn] :as cfg} :- Config 126 | raw-jwt :- s/Str 127 | jwt :- JWTClaims] 128 | (let-either [_ (check-jwt-expiry cfg jwt)] 129 | (if (fn? jwt-check-fn) 130 | (or (try (when-let [checks (seq (remove nil? (jwt-check-fn raw-jwt jwt)))] 131 | (->err :jwt_custom_check_fail 132 | (string/join ", " checks) 133 | {:jwt jwt 134 | :raw-jwt raw-jwt})) 135 | (catch Exception e 136 | (->err :jwt-custom-check-exception 137 | "jwt-check-fn threw an exception" 138 | {:level :error 139 | :exception e 140 | :raw-jwt raw-jwt 141 | :jwt jwt}))) 142 | (->pure :custom-checks-ok)) 143 | (->pure :no-custom-checks))))) 144 | 145 | (s/defn mk-wrap-authentication 146 | "A function building a middleware that will add some fields to the ring request: 147 | 148 | - :jwt that will contain the jwt claims 149 | - :identity that will contain an object derived from the JWT claims 150 | - :jwt-error if something went wrong 151 | 152 | To build the middleware the configuration is a map with the following fields: 153 | 154 | - pubkey-path ; should contain a path to the public key to be used to verify JWT signature 155 | - pubkey-fn ; should contain a function that once called will return the public key 156 | - pubkey-fn-arg-fn ; should contain a function that will be called to modify the argument (the raw JWT) of `pubkey-fn` 157 | - is-revoked-fn ; should be a function that takes a decoded jwt and return a non nil value if the jwt is revoked 158 | - jwt-check-fn ; should be a function taking a raw JWT string, and a decoded JWT and returns a list of errors or nil if no error is found. 159 | - jwt-max-lifetime-in-sec ; maximal lifetime of a JWT in seconds (takes priority over :exp) 160 | - post-jwt-format-fn ; a function taking a JWT and returning a data structure representing the identity of a user 161 | 162 | " 163 | [user-config :- UserConfig] 164 | (let [{:keys [pubkey-path 165 | pubkey-fn 166 | is-revoked-fn 167 | post-jwt-format-fn 168 | post-jwt-format-with-request-fn 169 | post-jwt-format-fn-arg-fn 170 | pubkey-fn-arg-fn] 171 | :as config} (->config user-config) 172 | p-fn (or pubkey-fn (constantly (public-key pubkey-path)))] 173 | (fn [handler] 174 | (fn [request] 175 | (let [authentication-result 176 | (let-either [raw-jwt (get-jwt request) 177 | {:keys [jwt]} (decode raw-jwt p-fn pubkey-fn-arg-fn) 178 | _ (validate-jwt config raw-jwt (:claims jwt)) 179 | _ (try (if-let [{:keys [error error_description] 180 | :as _revoked-result} (is-revoked-fn (:claims jwt))] 181 | (if (and (keyword? error) 182 | (string? error_description)) 183 | (->err error error_description {:jwt jwt}) 184 | (->err :jwt_revoked "JWT is revoked" {:jwt jwt})) 185 | (->pure :ok)) 186 | (catch Exception e 187 | (->err :jwt-revocation-fn-exception 188 | "is-revoked-fn thrown an exception" 189 | {:level :error 190 | :exception e 191 | :jwt jwt})))] 192 | (->pure {:identity (if post-jwt-format-with-request-fn 193 | (post-jwt-format-with-request-fn (post-jwt-format-fn-arg-fn jwt) request) 194 | (post-jwt-format-fn (post-jwt-format-fn-arg-fn jwt))) 195 | :jwt (:claims jwt)}))] 196 | (handler (into request (<-result authentication-result)))))))) 197 | 198 | (s/defschema RingRequest 199 | "we don't need to be more precise that saying this is an hash-map. 200 | The RingRequest schema is used as a documentation helper." 201 | {s/Any s/Any}) 202 | 203 | (s/defn authenticated? :- s/Bool 204 | [request :- RingRequest] 205 | (and (contains? request :jwt) 206 | (not (contains? request :jwt-error)))) 207 | 208 | (defn forbid-no-jwt-header-strategy 209 | "Forbid all request with no Auth header" 210 | [_handler] 211 | (constantly 212 | (resp/unauthorized {:error :invalid_request 213 | :error_description "No JWT found in HTTP Authorization header"}))) 214 | 215 | (def authorize-no-jwt-header-strategy 216 | "Authorize all request even with no Auth header." 217 | identity) 218 | 219 | (s/defn mk-wrap-authorization 220 | "A function building a middleware taking care of the authorization logic. 221 | 222 | It must be used in conjunction with `mk-wrap-authentication`. 223 | 224 | The configuration is map containing two handlers. 225 | 226 | - allow-unauthenticated-access? => set it to true to not block the request when no JWT is provided 227 | - error-handler => a function taking a JWT error (see Result) and returning a ring response. 228 | This function should generally just return a 401 (unauthorized)." 229 | [user-config :- UserConfig] 230 | (let [{:keys [allow-unauthenticated-access? 231 | error-handler]} (->config user-config)] 232 | (fn [handler] 233 | (let [no-jwt-fn (if allow-unauthenticated-access? 234 | (authorize-no-jwt-header-strategy handler) 235 | (forbid-no-jwt-header-strategy handler))] 236 | (fn [request] 237 | (if (authenticated? request) 238 | (handler request) 239 | (let [jwt-error (:jwt-error request)] 240 | (case (:error jwt-error) 241 | :no_jwt (no-jwt-fn request) 242 | nil (error-handler {:error :unauthenticated_user 243 | :error_description "No authenticated user."}) 244 | (error-handler jwt-error))))))))) 245 | 246 | 247 | (defn wrap-jwt-auth-fn 248 | "wrap a ring handler with JWT check both authentication and authorization mixed" 249 | [conf] 250 | (let [wrap-authentication (mk-wrap-authentication conf) 251 | wrap-authorization (mk-wrap-authorization conf)] 252 | (comp wrap-authentication wrap-authorization))) 253 | 254 | (defn wrap-jwt-auth-with-in-between-middleware-fn 255 | "Wrap the JWT authentication, authorization and a middleware wrapper in the middle 256 | 257 | The wrapper will have access to both: 258 | - the request with JWT details added by the authentication layer 259 | - the response status returned by the authorization layer. 260 | 261 | This is a good place to put a log middlware that will log all requests 262 | " 263 | [conf wrap-logs] 264 | (let [wrap-authentication (mk-wrap-authentication conf) 265 | wrap-authorization (mk-wrap-authorization conf)] 266 | (comp wrap-authentication wrap-logs wrap-authorization))) 267 | -------------------------------------------------------------------------------- /src/ring_jwt_middleware/result.clj: -------------------------------------------------------------------------------- 1 | (ns ring-jwt-middleware.result 2 | "This ns provides a set of helpers to handle an abstraction similar to Either in Haskell. 3 | 4 | The main goal is to provide a mechanism similar to the exceptions, albeit pure - without Java Exceptions. 5 | 6 | When a function returns a `Result` - it either contains a result of a successful outcome or an error with a 7 | common error structure (`JwtError`). 8 | 9 | The `let-either` macro provides a monadic syntax. 10 | Mainly: 11 | 12 | ``` 13 | (let-either [result-value-1 (fn-returning-a-result-1 ,,,) 14 | result-value-2 (fn-returning-a-result-2 ,,,) 15 | ,,,] 16 | ,,,) 17 | ``` 18 | 19 | if `fn-returning-a-result-1` returns an error then we will not execute the rest of the let-either. 20 | And return the full `result`. 21 | " 22 | (:require [schema.core :as s] 23 | [schema-tools.core :as st])) 24 | 25 | (s/defschema JwtError 26 | (st/open-schema 27 | {:error s/Keyword 28 | :error_description s/Str})) 29 | 30 | (s/defn result-of 31 | "Build a schema representing a result expecting succesful result with schema `s`" 32 | [s] 33 | {(s/optional-key :result) s 34 | (s/optional-key :jwt-error) JwtError}) 35 | 36 | (s/defschema Result 37 | "A result is similar to the Either in Haskell 38 | It represent either a value or an error" 39 | (result-of s/Any)) 40 | 41 | (s/defn ->pure :- Result 42 | "given a value build a result containing this value" 43 | [v] 44 | {:result v}) 45 | 46 | (s/defn ->err :- Result 47 | "build a Result that contain an error." 48 | [err-code :- s/Keyword 49 | err-description :- s/Str 50 | error-metas :- {s/Any s/Any}] 51 | {:jwt-error 52 | (into error-metas 53 | {:error err-code 54 | :error_description err-description})}) 55 | 56 | (s/defn error? :- s/Bool 57 | "return true if the given result is an Error" 58 | [m :- Result] 59 | (boolean (get m :jwt-error))) 60 | 61 | (s/defn success? :- s/Bool 62 | "return true if the given result is not an Error" 63 | [m :- Result] 64 | (not (error? m))) 65 | 66 | (s/defn <-result :- s/Any 67 | "Either returns the value or the error contained in the Result" 68 | [result :- Result] 69 | (if (error? result) 70 | result 71 | (:result result))) 72 | 73 | (defmacro let-either 74 | "To be used to handle cascading results that may depend on preceding values. 75 | If one of the function fail, we return the failed result. 76 | If all functions are successful we return the content of the body." 77 | {:special-form true 78 | :forms '[(let-either [bindings*] exprs*)] 79 | :style/indent 1} 80 | [bindings & body] 81 | (assert (vector? bindings) "let-either requires a vector for its bindings") 82 | (if (empty? bindings) 83 | `(do ~@body) 84 | (if (even? (count bindings)) 85 | `(let [result# ~(nth bindings 1)] 86 | (if (error? result#) 87 | result# 88 | (let [~(nth bindings 0) (<-result result#)] 89 | (let-either ~(subvec bindings 2) ~@body)))) 90 | (throw (IllegalArgumentException. 91 | "an even number of arguments is expected in the bindings"))))) 92 | -------------------------------------------------------------------------------- /src/ring_jwt_middleware/schemas.clj: -------------------------------------------------------------------------------- 1 | (ns ring-jwt-middleware.schemas 2 | "Schemas" 3 | (:require [schema-tools.core :as st] 4 | [schema.core :as s])) 5 | 6 | (s/defschema KeywordOrString 7 | (s/conditional keyword? s/Keyword 8 | :else s/Str)) 9 | 10 | (def JWT 11 | "A JWT is just a string" 12 | s/Str) 13 | 14 | (s/defschema JWTClaims 15 | (st/merge 16 | (st/optional-keys 17 | {:exp s/Num 18 | :nbf s/Num 19 | :iat s/Num 20 | :iss s/Str 21 | :sub s/Str 22 | :aud (s/conditional string? s/Str :else [s/Str]) 23 | :user_email s/Str}) 24 | {KeywordOrString s/Any})) 25 | 26 | (s/defschema JWTHeader 27 | (st/optional-keys 28 | {:alg s/Str 29 | :typ s/Str 30 | :kid s/Str})) 31 | 32 | (s/defschema JWTDecoded 33 | (st/optional-keys 34 | {:header JWTHeader 35 | :claims JWTClaims})) 36 | 37 | (defn describe 38 | "A function adding a description meta to schema. 39 | The main purpose is just schema annotation for the developers." 40 | [s description] 41 | (if (instance? clojure.lang.IObj s) 42 | (with-meta s {:description description}) 43 | s)) 44 | 45 | (s/defschema Config* 46 | (st/merge 47 | {:allow-unauthenticated-access? 48 | (describe s/Bool 49 | "Set this to true to allow unauthenticated requests") 50 | :current-epoch 51 | (describe (s/=> s/Num) 52 | "A function returning the current time in epoch format") 53 | :is-revoked-fn 54 | (describe (s/=> s/Bool JWTClaims) 55 | "A function that take a JWT and return true if it is revoked") 56 | :jwt-max-lifetime-in-sec 57 | (describe s/Num 58 | "Maximal number of second a JWT does not expires") 59 | :error-handler 60 | (describe (s/=> s/Any) 61 | "A function that given a JWTError returns a ring response.") 62 | :default-allowed-clock-skew-in-seconds 63 | (describe s/Num 64 | "When the JWT does not contain any nbf claim, the number of seconds to remove from iat claim. Default 60.")} 65 | (st/optional-keys 66 | {:post-jwt-format-fn 67 | (describe (s/=> s/Any JWTClaims) 68 | "A function taking the JWT claims and building an Identity object suitable for your needs") 69 | :post-jwt-format-with-request-fn 70 | (describe (s/=> s/Any JWTClaims) 71 | "A function taking the JWT claims and the request, and building an Identity object suitable for your needs") 72 | :pubkey-fn (describe (s/=> s/Any s/Str) 73 | "A function returning a public key (takes precedence over pubkey-path)") 74 | :pubkey-fn-arg-fn (describe (s/=> s/Any s/Any) 75 | "A function that will be applied to the argument (the raw JWT) of `pubkey-fn`") 76 | :post-jwt-format-fn-arg-fn (describe (s/=> s/Any s/Any) 77 | "A function that will be applied to the argument (the raw JWT) of `post-jwt-format-fn` or `post-jwt-format-with-request-fn`") 78 | :pubkey-path (describe s/Str 79 | "The path to find the public key that will be used to check the JWT signature") 80 | :jwt-check-fn 81 | (describe (s/=> s/Bool JWT JWTClaims) 82 | (str "A function that take a JWT, claims and return a sequence of string containing errors." 83 | "The check is considered successful if this function returns nil, or a sequence containing only nil values."))}))) 84 | 85 | (s/defschema Config 86 | "Initialized internal Configuration" 87 | (s/constrained 88 | Config* 89 | (fn [{:keys [post-jwt-format-fn post-jwt-format-with-request-fn]}] 90 | (or post-jwt-format-fn 91 | post-jwt-format-with-request-fn)) 92 | "One of `post-jwt-format-fn` or `post-jwt-format-with-request-fn` is required. `post-jwt-format-with-request-fn` has precedence.")) 93 | 94 | (s/defschema UserConfig 95 | "Middleware Configuration" 96 | (st/optional-keys Config*)) 97 | -------------------------------------------------------------------------------- /test/ring_jwt_middleware/config_test.clj: -------------------------------------------------------------------------------- 1 | (ns ring-jwt-middleware.config-test 2 | (:require [clojure.test :as t] 3 | [ring-jwt-middleware.config :as sut]) 4 | (:import java.lang.AssertionError)) 5 | 6 | (t/deftest init-config-test 7 | (t/is (= (str "Assert failed:" 8 | " The configuration should provide at least one of " 9 | "`pubkey-path` or `pukey-fn`" 10 | "\n(or pubkey-path pubkey-fn)") 11 | (try 12 | (sut/->config {}) 13 | (catch AssertionError e (.getMessage e))))) 14 | 15 | (t/is (= {:allow-unauthenticated-access? false 16 | :default-allowed-clock-skew-in-seconds 60, 17 | :current-epoch sut/current-epoch! 18 | :is-revoked-fn sut/no-revocation-strategy 19 | :jwt-max-lifetime-in-sec 86400 20 | :post-jwt-format-fn sut/jwt->user-id 21 | :post-jwt-format-fn-arg-fn :claims 22 | :pubkey-path "/some/path" 23 | :pubkey-fn-arg-fn :claims 24 | :error-handler sut/default-error-handler} 25 | (sut/->config {:pubkey-path "/some/path"}))) 26 | 27 | (t/is (sut/->config {:pubkey-fn (constantly "/some/path")}) 28 | "providing a pubkey-fn should be enough")) 29 | 30 | (t/deftest jwt->oauth-ids-test 31 | (t/is (= {:scopes #{"scope1" "scope2"}, 32 | :org {:id "org-id"}, 33 | :oauth {:client {:id "client-id"}}, 34 | :user {:id "user-id"}} 35 | (sut/jwt->oauth-ids 36 | "http://example.com/claims" 37 | {:sub "user-id" 38 | "http://example.com/claims/scopes" ["scope1" "scope2"] 39 | "http://example.com/claims/org/id" "org-id" 40 | "http://example.com/claims/oauth/client/id" "client-id"}))) 41 | 42 | (t/is (= {:scopes #{"scope1" "scope2"}, 43 | :org {:id "org-id"}, 44 | :oauth {:client {:id "client-id"}}, 45 | :user {:id "user-id"}} 46 | (sut/jwt->oauth-ids 47 | "http://example.com/claims" 48 | {:sub "user-id" 49 | "http://example.com/claims/scopes" ["scope1" "scope2"] 50 | "http://example.com/claims/user/id" "BAD-USER-ID" 51 | "http://example.com/claims/org/id" "org-id" 52 | "http://example.com/claims/oauth/client/id" "client-id"}))) 53 | 54 | (t/is (= {:user 55 | {:idp {:name "Visibility", :id "iroh"}, 56 | :name "John Doe", 57 | :email "john.doe@dev.null", 58 | :id "user-id"}, 59 | :oauth {:kind "code", :client {:id "client-id"}}, 60 | :org {:name "ACME Inc.", :id "org-id"}, 61 | :scopes #{"scope1" "scope2"}} 62 | (sut/jwt->oauth-ids 63 | "http://example.com/claims" 64 | {:sub "user-id" 65 | "http://example.com/claims/scopes" ["scope1" "scope2"] 66 | "http://example.com/claims/user/id" "user-id" 67 | "http://example.com/claims/user/name" "John Doe" 68 | "http://example.com/claims/user/email" "john.doe@dev.null" 69 | "http://example.com/claims/user/idp/id" "iroh" 70 | "http://example.com/claims/user/idp/name" "Visibility" 71 | "http://example.com/claims/org/id" "org-id" 72 | "http://example.com/claims/org/name" "ACME Inc." 73 | "http://example.com/claims/oauth/client/id" "client-id" 74 | "http://example.com/claims/oauth/kind" "code"})))) 75 | -------------------------------------------------------------------------------- /test/ring_jwt_middleware/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns ring-jwt-middleware.core-test 2 | (:require [clj-jwt.core :as jwt] 3 | [clj-jwt.intdate :refer [intdate->joda-time]] 4 | [clj-jwt.key :refer [public-key]] 5 | [clojure.test :refer [deftest is testing use-fixtures]] 6 | [java-time :as jt] 7 | [ring-jwt-middleware.core :as sut] 8 | [ring-jwt-middleware.config :as config] 9 | [ring-jwt-middleware.result :as result] 10 | [clojure.tools.logging] 11 | [clojure.tools.logging.impl] 12 | [schema.test])) 13 | 14 | (defn with-disabled-logs [t] 15 | (binding [clojure.tools.logging/*logger-factory* 16 | clojure.tools.logging.impl/disabled-logger-factory] 17 | (t))) 18 | 19 | (use-fixtures :each 20 | schema.test/validate-schemas 21 | with-disabled-logs) 22 | 23 | (def fixed-current-epoch (constantly 1498815302)) 24 | 25 | (defn make-jwt 26 | "a useful one liner for easy testing" 27 | [input-map privkey-name kid] 28 | (let [privkey (clj-jwt.key/private-key 29 | (str "resources/cert/" privkey-name ".key") "clojure")] 30 | (-> input-map 31 | jwt/jwt 32 | (jwt/sign :RS256 privkey kid) 33 | jwt/to-str))) 34 | 35 | (def epoch-to-time intdate->joda-time) 36 | 37 | (defn to-epoch 38 | "local date time to UTC-0 epoch in s" 39 | [d] 40 | (quot 41 | (jt/to-millis-from-epoch 42 | (jt/zoned-date-time d (jt/zone-id "UTC" (jt/zone-offset 0)))) 43 | 1000)) 44 | 45 | (defn const-d 46 | [& args] 47 | (constantly (to-epoch (apply jt/local-date-time args)))) 48 | 49 | (jt/available-zone-ids) 50 | 51 | (def jwt-token-1 52 | (make-jwt 53 | {:jti "r3e03ac6e-8d09-4d5e-8598-30e51a26dd2d" 54 | :exp (epoch-to-time 1499419023) 55 | :iat (epoch-to-time 1498814223) ;; 2017-06-30T09:17:03Z 56 | :nbf (epoch-to-time 1498813923) 57 | :sub "foo@bar.com" 58 | :iss "TEST-ISSUER-1" 59 | :user-identifier "foo@bar.com" 60 | :user_id "f0010924-e1bc-4b03-b600-89c6cf52757c" 61 | :foo "bar"} 62 | "jwt-key-1" 63 | "kid-1")) 64 | 65 | (def jwt-token-2 66 | (make-jwt 67 | {:jti "r3e03ac6e-8d09-4d5e-8598-30e51a26dd2d" 68 | :exp (epoch-to-time 1499419023) 69 | :iat (epoch-to-time 1498814223) ;; 2017-06-30T09:17:03Z 70 | :nbf (epoch-to-time 1498813923) 71 | :sub "foo@bar.com" 72 | :iss "TEST-ISSUER-2" 73 | :user-identifier "foo@bar.com" 74 | :user_id "f0010924-e1bc-4b03-b600-89c6cf52757c" 75 | :foo "bar"} 76 | "jwt-key-2" 77 | "kid-2")) 78 | 79 | (def jwt-token-3 80 | (make-jwt 81 | {:jti "r3e03ac6e-8d09-4d5e-8598-30e51a26dd2d" 82 | :exp (epoch-to-time 1499419023) 83 | :iat (epoch-to-time 1498814223) ;; 2017-06-30T09:17:03Z 84 | :nbf (epoch-to-time 1498813923) 85 | :sub "foo@bar.com" 86 | :iss "TEST-ISSUER-3" 87 | :user-identifier "foo@bar.com" 88 | :user_id "f0010924-e1bc-4b03-b600-89c6cf52757c" 89 | :foo "bar"} 90 | "jwt-key-3" 91 | "kid-3")) 92 | 93 | (def decoded-jwt-1 94 | "jwt-token-1 decoded" 95 | {:header {:alg "RS256" :typ "JWT" :kid "kid-1"} 96 | :claims 97 | {:jti "r3e03ac6e-8d09-4d5e-8598-30e51a26dd2d" 98 | :exp 1499419023 99 | :iat 1498814223 ;; 2017-06-30T09:17:03Z 100 | :nbf 1498813923 101 | :sub "foo@bar.com" 102 | :iss "TEST-ISSUER-1" 103 | :user-identifier "foo@bar.com" 104 | :user_id "f0010924-e1bc-4b03-b600-89c6cf52757c" 105 | :foo "bar"}}) 106 | 107 | (def decoded-jwt-1-claims 108 | (:claims decoded-jwt-1)) 109 | 110 | (def jwt-signed-with-wrong-key 111 | (make-jwt 112 | {:jti "r3e03ac6e-8d09-4d5e-8598-30e51a26dd2d" 113 | :exp (epoch-to-time 1499419023) 114 | :iat (epoch-to-time 1498814223) ;; 2017-06-30T09:17:03Z 115 | :nbf (epoch-to-time 1498813923) 116 | :sub "foo@bar.com" 117 | :iss "TEST-ISSUER-1" 118 | :user-identifier "foo@bar.com" 119 | :user_id "f0010924-e1bc-4b03-b600-89c6cf52757c" 120 | :foo "bar"} 121 | "jwt-key-2" 122 | "kid-2")) 123 | 124 | (def decoded-jwt-2 125 | {:user-identifier "bar@foo.com", 126 | :iss "TEST-ISSUER-1" 127 | :iat 1487168050 ;; 2017-02-15T14:14:10Z 128 | :exp (+ 1487168050 (* 7 24 60 60)) 129 | :nbf (- 1487168050 (* 5 24 60 60))}) 130 | 131 | (def decoded-no-nbf-jwt 132 | {:user-identifier "bar@foo.com", 133 | :iss "TEST-ISSUER-1" 134 | :iat 1487168050 ;; 2017-02-15T14:14:10Z 135 | :exp (+ 1487168050 (* 7 24 60 60))}) 136 | 137 | (def pubkey1 (public-key "resources/cert/jwt-key-1.pub")) 138 | (def pubkey2 (public-key "resources/cert/jwt-key-2.pub")) 139 | (def pubkey3 (public-key "resources/cert/jwt-key-3.pub")) 140 | 141 | (deftest decode-test 142 | (is (= decoded-jwt-1 143 | (:jwt (result/<-result (sut/decode jwt-token-1 (constantly pubkey1) :claims))))) 144 | (is (result/error? (sut/decode jwt-signed-with-wrong-key (constantly pubkey1) :claims))) 145 | (is (= {:error :jwt_invalid_signature, :error_description "Invalid Signature"} 146 | (-> (sut/decode jwt-signed-with-wrong-key (constantly pubkey1) :claims) 147 | :jwt-error 148 | (select-keys [:error :error_description]))))) 149 | 150 | (deftest validate-errors-test 151 | (let [cfg (config/->config {:current-epoch fixed-current-epoch 152 | :pubkey-path "resources/cert/jwt-key-1.pub"})] 153 | 154 | (is (result/success? (sut/validate-jwt cfg "jwt" decoded-jwt-1-claims))) 155 | (is (= {:jwt-error {:jwt {} 156 | :error :jwt_missing_field 157 | :error_description 158 | "This JWT doesn't contain the following fields #{:exp :iat}"}} 159 | (sut/validate-jwt cfg "jwt" {}))) 160 | (is (= {:jwt-error {:jwt {:user-identifier "foo@bar.com", :iat 1487168050}, 161 | :error :jwt_missing_field, 162 | :error_description 163 | "This JWT doesn't contain the following fields #{:exp}"}} 164 | (sut/validate-jwt cfg "jwt" {:user-identifier "foo@bar.com" :iat 1487168050}))) 165 | (testing "custom check-fn" 166 | (is (= {:jwt-error 167 | {:jwt 168 | {:user-identifier "foo@bar.com", 169 | :sub "foo@bar.com", 170 | :iss "TEST-ISSUER-1", 171 | :exp 1499419023, 172 | :jti "r3e03ac6e-8d09-4d5e-8598-30e51a26dd2d", 173 | :nbf 1498813923, 174 | :foo "bar", 175 | :user_id "f0010924-e1bc-4b03-b600-89c6cf52757c", 176 | :iat 1498814223}, 177 | :raw-jwt "jwt", 178 | :error :jwt_custom_check_fail, 179 | :error_description "SOMETHING BAD HAPPENED"}} 180 | (sut/validate-jwt 181 | (assoc cfg 182 | :jwt-check-fn 183 | (fn [_raw-jwt _jwt] ["SOMETHING BAD HAPPENED"])) 184 | "jwt" 185 | decoded-jwt-1-claims)))) 186 | (testing "check-fn throw an exception" 187 | (is (= {:jwt-error 188 | {:level :error, 189 | :raw-jwt "jwt", 190 | :jwt 191 | {:user-identifier "foo@bar.com", 192 | :sub "foo@bar.com", 193 | :iss "TEST-ISSUER-1", 194 | :exp 1499419023, 195 | :jti "r3e03ac6e-8d09-4d5e-8598-30e51a26dd2d", 196 | :nbf 1498813923, 197 | :foo "bar", 198 | :user_id "f0010924-e1bc-4b03-b600-89c6cf52757c", 199 | :iat 1498814223}, 200 | :error :jwt-custom-check-exception, 201 | :error_description "jwt-check-fn threw an exception"}} 202 | (-> (try 203 | (sut/validate-jwt 204 | (assoc cfg 205 | :jwt-check-fn 206 | (fn [_raw-jwt _jwt] (throw (ex-info "check-fn fail test" {:test-infos :test})))) 207 | "jwt" 208 | decoded-jwt-1-claims) 209 | (catch Exception e (.getMessage e))) 210 | (update :jwt-error dissoc :exception))))) 211 | 212 | (testing "check-fn fail by using the raw-jwt" 213 | (is (= {:jwt-error 214 | {:raw-jwt "jwt" 215 | :jwt 216 | {:user-identifier "foo@bar.com", 217 | :sub "foo@bar.com", 218 | :iss "TEST-ISSUER-1", 219 | :exp 1499419023, 220 | :jti "r3e03ac6e-8d09-4d5e-8598-30e51a26dd2d", 221 | :nbf 1498813923, 222 | :foo "bar", 223 | :user_id "f0010924-e1bc-4b03-b600-89c6cf52757c", 224 | :iat 1498814223}, 225 | :error :jwt_custom_check_fail, 226 | :error_description "jwt"}} 227 | (try (sut/validate-jwt (assoc cfg :jwt-check-fn (fn [raw-jwt _jwt] [raw-jwt])) 228 | "jwt" 229 | decoded-jwt-1-claims) 230 | (catch Exception e (.getMessage e)))))) 231 | 232 | (testing "expiration message" 233 | (testing "expired time" 234 | (let [explain-msg "This JWT has expired %s ago (we don't allow JWT older than 1 day; we only check creation date and not maximal expiration date)" 235 | tst-fn (fn [d expected] 236 | (is (= (format explain-msg expected) 237 | (-> (sut/validate-jwt (assoc cfg :current-epoch d) "jwt" decoded-jwt-2) 238 | :jwt-error 239 | :error_description))))] 240 | (tst-fn (const-d 2017 02 16 14 14 11) "1s") 241 | (tst-fn (const-d 2017 02 16 15 14 10 0) "1h") 242 | (tst-fn (const-d 2017 02 17 15 14 10 0) "1 day 1h") 243 | (tst-fn (const-d 2017 02 18 15 14 10 0) "2 days 1h") 244 | (tst-fn (const-d 2019 04 03 8 24 5 123) "2 years 45 days 18h 9min 55s") 245 | (is (= (format explain-msg "1s") 246 | (-> (sut/validate-jwt (assoc cfg :current-epoch (const-d 2017 02 16 14 14 11)) "jwt" decoded-jwt-2) 247 | :jwt-error 248 | :error_description)) 249 | "Default maximal JWT lifetime should be set to 1 day"))) 250 | (testing "expired time for missing nbf JWT" 251 | (let [explain-msg "This JWT has expired %s ago (we don't allow JWT older than 1 day; we only check creation date and not maximal expiration date)" 252 | tst-fn (fn [d expected] 253 | (is (= (format explain-msg expected) 254 | (-> (sut/validate-jwt (assoc cfg :current-epoch d) "jwt" decoded-no-nbf-jwt) 255 | :jwt-error 256 | :error_description))))] 257 | (tst-fn (const-d 2017 02 16 14 14 11) "1s") 258 | (tst-fn (const-d 2017 02 16 15 14 10 0) "1h") 259 | (tst-fn (const-d 2017 02 17 15 14 10 0) "1 day 1h") 260 | (tst-fn (const-d 2017 02 18 15 14 10 0) "2 days 1h") 261 | (tst-fn (const-d 2019 04 03 8 24 5 123) "2 years 45 days 18h 9min 55s") 262 | (is (= (format explain-msg "1s") 263 | (-> (sut/validate-jwt (assoc cfg :current-epoch (const-d 2017 02 16 14 14 11)) "jwt" decoded-no-nbf-jwt) 264 | :jwt-error 265 | :error_description)) 266 | "Default maximal JWT lifetime should be set to 1 day"))) 267 | 268 | (testing "nbf message" 269 | (testing "with nbf" 270 | (let [explain-msg "This JWT will be valid in %s" 271 | tst-fn (fn [d expected] 272 | (is (= (format explain-msg expected) 273 | (-> (sut/validate-jwt (assoc cfg :current-epoch d) "jwt" decoded-jwt-2) 274 | :jwt-error 275 | :error_description))))] 276 | ;; 5 days and 1s before :iat 277 | (tst-fn (const-d 2017 02 10 14 14 9) "1s") 278 | (tst-fn (const-d 2017 02 10 13 14 9) "1h 1s"))) 279 | (testing "missing nbf" 280 | (testing "default clock skew" 281 | (let [explain-msg "This JWT will be valid in %s" 282 | tst-fn (fn [d expected] 283 | (is (= (format explain-msg expected) 284 | (-> (sut/validate-jwt (assoc cfg :current-epoch d) "jwt" decoded-no-nbf-jwt) 285 | :jwt-error 286 | :error_description))))] 287 | ;; 1 minute before :iat 288 | (tst-fn (const-d 2017 02 15 14 13 9) "1s") 289 | (tst-fn (const-d 2017 02 15 14 12 9) "1min 1s"))) 290 | (testing "overriden clock skew (set to 0)" 291 | (let [explain-msg "This JWT will be valid in %s" 292 | tst-fn (fn [d expected] 293 | (is (= (format explain-msg expected) 294 | (-> (sut/validate-jwt (assoc cfg :current-epoch d 295 | :default-allowed-clock-skew-in-seconds 0) 296 | "jwt" decoded-no-nbf-jwt) 297 | :jwt-error 298 | :error_description))))] 299 | ;; 1 minute before :iat 300 | (tst-fn (const-d 2017 02 15 14 13 9) "1min 1s") 301 | (tst-fn (const-d 2017 02 15 14 12 9) "2min 1s"))))) 302 | 303 | (testing "max lifetime" 304 | (let [explain-msg "This JWT has expired %s ago (we don't allow JWT older than %s; we only check creation date and not maximal expiration date)" 305 | tst-fn (fn [d max-lifetime expected expected-max] 306 | (is (= (format explain-msg expected expected-max) 307 | (-> (sut/validate-jwt (assoc cfg 308 | :jwt-max-lifetime-in-sec max-lifetime 309 | :current-epoch d) 310 | "jwt" decoded-jwt-2) 311 | :jwt-error :error_description))))] 312 | (tst-fn (const-d 2017 02 16 14 14 11) 86400 "1s" "1 day") 313 | (tst-fn (const-d 2017 02 16 14 14 11) 86300 "1min 41s" "23h 58min 20s")))))) 314 | 315 | (deftest get-jwt-test 316 | (testing "get-jwt requests containing a JWT" 317 | (is (= {:result "foo"} 318 | (sut/get-jwt {:headers {"authorization" "Bearer foo"}})))) 319 | (testing "get-jwt requests without no JWT" 320 | (is (= {:jwt-error {:error :no_jwt, :error_description "No JWT found in HTTP headers"}} 321 | (sut/get-jwt {:headers {"authorization" "Bearer"}}))) 322 | (is (= {:jwt-error {:error :no_jwt, :error_description "No JWT found in HTTP headers"}} 323 | (sut/get-jwt {:headers {"bad" "Bearer foo"}}))))) 324 | 325 | (defn with-mid [cfg handler] 326 | (let [wrapper 327 | (sut/wrap-jwt-auth-fn (into {:pubkey-path "resources/cert/jwt-key-1.pub"} 328 | cfg))] 329 | (wrapper handler))) 330 | 331 | (deftest wrap-jwt-auth-fn-test 332 | (let [handler (fn [req] {:status 200 333 | :body (select-keys req [:jwt :identity :jwt-error])}) 334 | req {:headers {"authorization" (str "Bearer " jwt-token-1)}} 335 | req-bad-jwt {:headers {"authorization" (str "Bearer x" jwt-token-1)}} 336 | req-no-header {} 337 | req-auth-header-not-jwt {:headers {"authorization" "api-key 1234-1234-1234-1234"}} 338 | 339 | handler-with-mid-cfg 340 | (fn [cfg] 341 | (fn [req] 342 | (let [wrapper (sut/wrap-jwt-auth-fn 343 | (into 344 | {:pubkey-path "resources/cert/jwt-key-1.pub" 345 | :current-epoch (const-d 2017 06 30 11 32 10)} 346 | cfg)) 347 | ring-fn (wrapper handler)] 348 | (ring-fn req))))] 349 | (testing "Basic usage" 350 | (let [ring-fn (handler-with-mid-cfg {})] 351 | (is (= {:status 200 352 | :body {:jwt {:user-identifier "foo@bar.com", 353 | :sub "foo@bar.com", 354 | :iss "TEST-ISSUER-1", 355 | :exp 1499419023, 356 | :jti "r3e03ac6e-8d09-4d5e-8598-30e51a26dd2d", 357 | :nbf 1498813923, 358 | :foo "bar", 359 | :user_id "f0010924-e1bc-4b03-b600-89c6cf52757c", 360 | :iat 1498814223}, 361 | :identity "foo@bar.com"}} 362 | (ring-fn req))) 363 | 364 | (is (= {:status 401, 365 | :body 366 | {:error :jwt_decode_failed_exception, 367 | :error_description "JWT decode failed"}} 368 | (-> (ring-fn req-bad-jwt) 369 | (select-keys [:status :body]) 370 | (update :body select-keys [:error :error_description])))) 371 | (is (= {:status 401 372 | :body {:error :invalid_request, 373 | :error_description "No JWT found in HTTP Authorization header"}} 374 | (-> (ring-fn req-no-header) 375 | (select-keys [:status :body]) 376 | (update :body select-keys [:error :error_description])))) 377 | (is (= {:status 401 378 | :body {:error :invalid_request, 379 | :error_description "No JWT found in HTTP Authorization header"}} 380 | (-> (ring-fn req-auth-header-not-jwt) 381 | (select-keys [:status :body]) 382 | (update :body select-keys [:error :error_description])))))) 383 | (testing "The JWT should be expired after 24h" 384 | (let [ring-fn (handler-with-mid-cfg {:current-epoch (const-d 2017 07 1 9 17 4)})] 385 | (is (= 401 386 | (:status (ring-fn req))))) 387 | (let [ring-fn (handler-with-mid-cfg {:current-epoch (const-d 2017 07 1 9 17 3)})] 388 | (is (= 200 389 | (:status (ring-fn req)))))) 390 | 391 | (testing "multiple keys support" 392 | (let [pubkey-fn (fn [claims] 393 | (case (:iss claims) 394 | "TEST-ISSUER-1" pubkey1 395 | "TEST-ISSUER-2" pubkey2)) 396 | ring-fn (handler-with-mid-cfg {:pubkey-fn pubkey-fn}) 397 | req {:headers {"authorization" (str "Bearer " jwt-token-1)}} 398 | req-2 {:headers {"authorization" (str "Bearer " jwt-token-2)}} 399 | req-3 {:headers {"authorization" (str "Bearer " jwt-token-3)}} 400 | response-1 (ring-fn req) 401 | response-2 (ring-fn req-2) 402 | response-3 (ring-fn req-3)] 403 | (is (= 200 (:status response-1))) 404 | (is (= 200 (:status response-2))) 405 | (is (= 401 (:status response-3))) 406 | (is (= :jwt_decode_failed_exception 407 | (get-in response-3 [:body :error])))) 408 | 409 | (testing "multiple keys based on the `kid`" 410 | (let [pubkey-fn (fn [kid] 411 | (case kid 412 | "kid-1" pubkey1 413 | "kid-2" pubkey2)) 414 | pubkey-fn-arg-fn #(get-in % [:header :kid]) 415 | ring-fn (handler-with-mid-cfg {:pubkey-fn pubkey-fn 416 | :pubkey-fn-arg-fn pubkey-fn-arg-fn}) 417 | req {:headers {"authorization" (str "Bearer " jwt-token-1)}} 418 | req-2 {:headers {"authorization" (str "Bearer " jwt-token-2)}} 419 | req-3 {:headers {"authorization" (str "Bearer " jwt-token-3)}} 420 | response-1 (ring-fn req) 421 | response-2 (ring-fn req-2) 422 | response-3 (ring-fn req-3)] 423 | (is (= 200 (:status response-1))) 424 | (is (= 200 (:status response-2))) 425 | (is (= 401 (:status response-3))) 426 | (is (= :jwt_decode_failed_exception 427 | (get-in response-3 [:body :error])))))) 428 | 429 | (testing "Authorized No Auth Header strategy test" 430 | (let [ring-fn (handler-with-mid-cfg {:allow-unauthenticated-access? true})] 431 | (is (= {:status 200 432 | :body {:jwt {:user-identifier "foo@bar.com", 433 | :sub "foo@bar.com", 434 | :iss "TEST-ISSUER-1", 435 | :exp 1499419023, 436 | :jti "r3e03ac6e-8d09-4d5e-8598-30e51a26dd2d", 437 | :nbf 1498813923, 438 | :foo "bar", 439 | :user_id "f0010924-e1bc-4b03-b600-89c6cf52757c", 440 | :iat 1498814223}, 441 | :identity "foo@bar.com"}} 442 | (ring-fn req))) 443 | 444 | (is (= {:status 401, 445 | :body 446 | {:error :jwt_decode_failed_exception, 447 | :error_description "JWT decode failed"}} 448 | (-> (ring-fn req-bad-jwt) 449 | (select-keys [:status :body]) 450 | (update :body select-keys [:error :error_description])))) 451 | (is (= {:status 200 452 | :body 453 | {:jwt-error 454 | {:error :no_jwt, :error_description "No JWT found in HTTP headers"}}} 455 | (ring-fn req-no-header))) 456 | (is (= {:status 200 457 | :body 458 | {:jwt-error 459 | {:error :no_jwt, :error_description "No JWT found in HTTP headers"}}} 460 | (ring-fn req-auth-header-not-jwt))))) 461 | 462 | (testing "revocation test" 463 | (let [revoke-handler (handler-with-mid-cfg {:is-revoked-fn (constantly true)}) 464 | no-revoke-handler (handler-with-mid-cfg {:is-revoked-fn (constantly false)})] 465 | (is (= 401 (:status (revoke-handler req)))) 466 | (is (= 200 (:status (no-revoke-handler req)))) 467 | (is (= "foo@bar.com" 468 | (get-in (no-revoke-handler req) [:body :identity])))) 469 | (let [revoke-handler (handler-with-mid-cfg {:is-revoked-fn (constantly {:error :internal-error :error_description "Internal Error"})}) 470 | no-revoke-handler (handler-with-mid-cfg {:is-revoked-fn (constantly false)})] 471 | (is (= 401 (:status (revoke-handler req)))) 472 | (is (= {:error :internal-error 473 | :error_description "Internal Error"} 474 | (select-keys (:body (revoke-handler req)) 475 | [:error :error_description])) 476 | "is-revoked-fn can provide specific errors") 477 | (is (= 200 (:status (no-revoke-handler req)))) 478 | (is (= "foo@bar.com" 479 | (get-in (no-revoke-handler req) [:body :identity]))))) 480 | 481 | (testing "post jwt transformation test" 482 | (let [post-transform (fn [m] {:user {:id (:sub m)} 483 | :org {:id (:foo m)}}) 484 | ring-fn (handler-with-mid-cfg {:post-jwt-format-fn post-transform})] 485 | (is (= 200 (:status (ring-fn req)))) 486 | (is (= {:user {:id "foo@bar.com"} 487 | :org {:id "bar"}} 488 | (get-in (ring-fn req) [:body :identity]))))) 489 | 490 | (testing "post-jwt-format-with-request-fn takes precedence over post-jwt-format-fn" 491 | (let [post-transform (fn [m] {:user {:id (:sub m)} 492 | :org {:id (:foo m)}}) 493 | post-transform-with-request (fn [m _req] {:user {:id (:sub m)} 494 | :org {:id (:foo m)} 495 | :headers-available? true}) 496 | ring-fn (handler-with-mid-cfg {:post-jwt-format-fn post-transform 497 | :post-jwt-format-with-request-fn post-transform-with-request})] 498 | (is (= 200 (:status (ring-fn req)))) 499 | (is (= {:user {:id "foo@bar.com"} 500 | :org {:id "bar"} 501 | :headers-available? true} 502 | (get-in (ring-fn req) [:body :identity]))))) 503 | 504 | (testing "post jwt transformation test using `post-jwt-format-fn-arg-fn`" 505 | (let [post-transform (fn [m] {:user {:id (-> m :claims :sub)} 506 | :org {:id (-> m :claims :foo)} 507 | :jwk {:kid "kid-1"}}) 508 | ring-fn (handler-with-mid-cfg {:post-jwt-format-fn post-transform 509 | :post-jwt-format-fn-arg-fn identity})] 510 | (is (= 200 (:status (ring-fn req)))) 511 | (is (= {:user {:id "foo@bar.com"} 512 | :org {:id "bar"} 513 | :jwk {:kid "kid-1"}} 514 | (get-in (ring-fn req) [:body :identity]))))))) 515 | --------------------------------------------------------------------------------