Initial implementation

Features:
- JWKS support
- Cache handling of jwks
- Some Buddy api compatibility

Done:

- Some specced functions
- Tests
- Spec exercise tests

Todo:

- Spec all functions
- More tests?
- Documentation
Signed-off-by: Snorre Magnus Davøen's avatarSnorre Magnus Davøen <snorre.davoen@nsd.no>
parents
Pipeline #13750 passed with stage
in 34 seconds
/target
/classes
/checkouts
pom.xml
pom.xml.asc
*.jar
*.class
/.lein-*
/.nrepl-port
.hgignore
.hg/
stages:
- test
cache:
key: always
paths:
- ./.m2
before_script:
- cp -R ./.m2 $HOME || true
after_script:
- cp -R $HOME/.m2 .
test:
image: clojure:lein-alpine
script:
- lein test
# Change Log
All notable changes to this project will be documented in this file. This change log follows the conventions of [keepachangelog.com](http://keepachangelog.com/).
## [Unreleased]
### Changed
- Add a new arity to `make-widget-async` to provide a different widget shape.
## [0.1.1] - 2018-09-12
### Changed
- Documentation on how to make the widgets.
### Removed
- `make-widget-sync` - we're all async, all the time.
### Fixed
- Fixed widget maker to keep working when daylight savings switches over.
## 0.1.0 - 2018-09-12
### Added
- Files from the new template.
- Widget maker public API - `make-widget-sync`.
[Unreleased]: https://github.com/your-name/clj-jwt/compare/0.1.1...HEAD
[0.1.1]: https://github.com/your-name/clj-jwt/compare/0.1.0...0.1.1
This diff is collapsed.
# clj-jwt
A Clojure library to handle validation of JWTs.
The library exposes functions to handle validation of JSON web tokens. It wraps
some of [Buddy's](https://funcool.github.io/buddy-sign/latest/) jwt signature
handling functions and uses a JWKS endpoint to fetch the public keys to use for
signature validation.
## Usage
TBD
## License
Copyright © 2018 NSD - NORSK SENTER FOR FORSKNINGSDATA AS
Distributed under the Eclipse Public License either version 1.0 or (at
your option) any later version.
(defproject no.nsd/clj-jwt "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.9.0"]
[buddy/buddy-core "1.5.0"]
[buddy/buddy-sign "3.0.0"]
[org.clojure/data.json "0.2.6"]
[org.clojure/algo.generic "0.1.3"]
[invetica/uri "0.5.0"]]
:profiles {:test {:resource-paths ["test-resources"]
:dependencies [[org.clojure/test.check "0.9.0"]
[clj-time "0.14.4"]
[clojure-term-colors "0.1.0"]]}})
(ns no.nsd.clj-jwt
(:require [buddy.core.keys :as keys]
[buddy.core.keys.jwk.proto :as buddy-jwk]
[buddy.sign.jwt :as jwt]
[clojure.algo.generic.functor :refer [fmap]]
[clojure.data.json :as json]
[clojure.java.io :refer [resource]]
[clojure.spec.gen.alpha :as gen]
[clojure.spec.alpha :as s]
[invetica.uri :as uri]))
(def jwtregex #"^[a-zA-Z0-9\-_=]+?\.[a-zA-Z0-9\-_=]+?\.[a-zA-Z0-9\-_=]+?$")
(s/def ::sub (s/nilable :no.nsd.authorizer/uuid))
(s/def ::kid string?)
(s/def ::scope (s/nilable string?))
(s/def ::scopes (s/nilable (s/coll-of string? :kind set?)))
(s/def ::exp (s/and integer?
pos?))
(s/def ::kty (s/with-gen (s/and string?
#(= "RSA" %))
#(s/gen #{"RSA"})))
(s/def ::n (s/with-gen string?
#(s/gen #{;;valid:
"nZq9S6leC-8Se5-VlHcVZ0HVpQRwFNuZRp82WFddhMZUoEEKybuiym6uNh5kquNADbZcRw4yxJI3BBuWLoOz-YBjXlnxNqeQgr2E8LZ_AsT-6Yb6xdKrZ5acXaLAQsXwk53GHhUcFzOFu3u6BXVMknCY6jI6dxgOlSlWQV2nCjWTio_cTbDjsSSfIQ9jWcK9aCmw37omCZqIXlLwGA9fD4Ah8c4-QTfV7dZ7q_MQmrCqv88_eYAvg-lUlUQRnB9jGg53MWlitYGKW_aUr8oRn7nHm-gsXtL_bzWLxSSbkxiht52e4mcFNOXAqXVlocW1YJC3weRojI-CXJZ6218z6Q"
;;invalid:
"xnmcbvjksdhfwiuerfsdjbsdkjfghwileugkhjbvnxdvjbvwiuerhslkdjbvvklwl4iuhjxcvxnmbvkwerjlfhiwuerhsjdkdfkjbvwe4riefslkv-dlsfkjhwpoiefhcvsdjkhvowpefwoeifhv_sdøflkhjwpeoifhsvøkl"})))
(s/def ::e string?)
(s/def ::subject (s/nilable (s/keys :req-un [::sub ::exp]
:opt-un [::scope
::scopes])))
(s/def ::jwt (s/nilable (s/and string?
#(re-matches jwtregex %))))
(s/def ::jwk (s/keys :req-un [::kty ::e ::n ::kid]))
(s/def ::RSAPublicKey keys/public-key?)
(s/def ::key-store (s/map-of ::kid
::RSAPublicKey))
(s/def ::resource (s/with-gen #(instance? java.net.URL %)
#(s/gen #{(resource "jwks.json")
(resource "jwks-other.json")})))
(s/fdef jwks-edn->public-keys
:args (s/cat :jwks (s/coll-of ::jwk :type vector?))
:ret ::key-store)
(defn jwks-edn->public-keys
"Transform vector of json-web-keys to map of kid -> PublicKey pairs."
[json-web-keys]
(->> json-web-keys
:keys
(filter #(= (:kty %) "RSA"))
(group-by :kid)
(fmap first)
(fmap buddy-jwk/jwk->public-key)))
(s/fdef fetch-keys
:args (s/cat :jwks-url (s/or :url :invetica.uri/absolute-uri
:resource ::resource))
:ret ::key-store)
(defn fetch-keys
"Fetches the jwks from the supplied jwks-url and converts to java Keys.
Returns a map keyed on key-id where each value is a RSAPublicKey object"
[jwks-url]
(->> jwks-url
slurp
(#(json/read-str % :key-fn keyword))
jwks-edn->public-keys))
(def public-keys
"Atom to hold the public keys used for signature validation in memory for
caching purposes. The atom holds a clojure map with kid -> PublicKey pairs."
(atom {}))
(defn refresh-keys
"Fetches keys and updates key store (atom)"
[jwks-url]
(reset! public-keys
(fetch-keys jwks-url)))
(defn resolve-key
"Returns java.security.PublicKey given jwks-url and :kid in jwt-header.
If no key is found refreshes"
[jwks-url jwt-header]
(let [key-fn (fn [] (get (deref public-keys) (:kid jwt-header)))]
(if-let [key (key-fn)]
key
(do (reset! public-keys (fetch-keys jwks-url))
(if-let [key (key-fn)]
key
(throw (ex-info "Could not locate public key corresponding to jwt header's kid."
{:type :validation :cause :unknown-key})))))))
(defn unsign
"Given token, jwks-url, and optionally opts validates and returns the claims
of the given json web token. Opts are the same as buddy-sign.jwt/unsign."
([token jwks-url]
(unsign token jwks-url {}))
([token jwks-url opts]
(jwt/unsign token (partial resolve-key jwks-url) (merge {:alg :rs256} opts))))
{
"keys": [
{
"kty": "RSA",
"e": "AQAB",
"kid": "cA7qG2gBw7A7IBW4MZgpYopzRaLyksCL4hQexAXn_eE",
"n": "nZq9S6leC-8Se5-VlHcVZ0HVpQRwFNuZRp82WFddhMZUoEEKybuiym6uNh5kquNADbZcRw4yxJI3BBuWLoOz-YBjXlnxNqeQgr2E8LZ_AsT-6Yb6xdKrZ5acXaLAQsXwk53GHhUcFzOFu3u6BXVMknCY6jI6dxgOlSlWQV2nCjWTio_cTbDjsSSfIQ9jWcK9aCmw37omCZqIXlLwGA9fD4Ah8c4-QTfV7dZ7q_MQmrCqv88_eYAvg-lUlUQRnB9jGg53MWlitYGKW_aUr8oRn7nHm-gsXtL_bzWLxSSbkxiht52e4mcFNOXAqXVlocW1YJC3weRojI-CXJZ6218z6Q"
}
]
}
\ No newline at end of file
{
"keys": [{
"kid": "test-key",
"kty": "RSA",
"e": "AQAB",
"n": "0UxySsp7z5_nc119mtuIaYVBYKJYrDuvI_7nK1ynOzkNqj3U81Z5jVzcYfieVMV0XxNyvYQyUivkkhzCQTx_No1cSry7vV8ShR2JVj3HJbs96ag32hjTztdvevAwJXcLwFeoUxoMe6DGjT7tFJbYA_2mo-pzuces2TbjFm_G5TE"
}]
}
\ No newline at end of file
(ns no.nsd.clj-jwt-test
(:require [no.nsd.clj-jwt :as clj-jwt]
[buddy.sign.jwt :as buddy-jwt]
[buddy.core.keys.jwk.proto :as buddy-jwk]
[buddy.core.keys :as buddy-keys]
[clojure.java.io :refer [resource]]
[clojure.test :refer [deftest testing is]]))
(def example-jwt "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")
(def private-rsa-key
"-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,27176ED7CC3B4C5F1061DFA491E31FE0
sAxVEIQN6aFka4ApVmag0MO5ZdhEaV0bz1dADLWnBBXRtOdyavYPDJRrzC0TgmZ5
TLUWNEeB60ja1C0iV3TskCOZ6/eMM+/ipISLbYdrz1lJ2UP0Nz+snbi4sNwvU0+t
BTe6ntB9t/cR8n7IaECN5TsaFTrVxKwg5MMXJAEMQKvC5wviNZV+mH/FlYiUG7tr
XzNOdvBkJJO2fn8JD4faQyr8bja5A9Mf0L9Z1ecBX/aGM7AjtqlUXsKbnpWnGhvY
ETLmJ19fWplSKFbsm6olc64/OLZ3m5uXTCXFviUQDZP0duu1+kXzYz2LQSIQYJy8
u4vfiWA4EDgRtbudOv+kdtRMDmY2E1H2zeJKxOpc9Z3khEjtnpoxOV+C3x+pvbfk
Lbaip/LbYujtHt70211GfZCwBBcz8X875DxqxOwg8oalPuakgI18kE+XnQ6IWB1E
NijAi4a52IDdeVsA0LDPFAcnIRed/uIeYXt7FSVfcEAgjk1K7geqa8nQP1TnPO2t
AClIq24BZ14fCpn+apup4mtMaecC8ubOmOs6UmFTZMYqQQECyFgcWx7mCaMJ2iUp
9nJQh+4brIgzIhUSweU7N02W75p+X27tUIKnHTrNIqLRudBisCUGO+oAr0e6YiTd
bM/n5trY7QVwFPiu12YY+fdZWVDa0v7Wz47+V6zBkKDd/HDB5t4zc7B7zZ9RZxs0
Yejev7Z+yRk6aZpBi+6dp/8adS5hVFGnDhKYusi03x0mBBMb1c/IPt72ZcMO5JuW
9IHZoq9ZbYk9UkQLtTgBexfK9np6vqdrTH05+wrskbSk3Gnb8uBUoUMt4AopGnlY
-----END RSA PRIVATE KEY-----")
(def public-rsa-key
"-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDRTHJKynvPn+dzXX2a24hphUFg
olisO68j/ucrXKc7OQ2qPdTzVnmNXNxh+J5UxXRfE3K9hDJSK+SSHMJBPH82jVxK
vLu9XxKFHYlWPccluz3pqDfaGNPO12968DAldwvAV6hTGgx7oMaNPu0UltgD/aaj
6nO5x6zZNuMWb8blMQIDAQAB
-----END PUBLIC KEY-----")
(def ec-privkey (buddy-keys/str->private-key private-rsa-key "secret"))
(def ec-pubkey (buddy-keys/str->public-key public-rsa-key))
(def jwt-payload {:sub "asd"})
(def signed-jwt (buddy-jwt/sign jwt-payload ec-privkey {:alg :rs256 :header {:kid "test-key"}}))
(def signed-jwt-wrongkey (buddy-jwt/sign jwt-payload ec-privkey {:alg :rs256 :header {:kid "wrong-key"}}))
(deftest jwt-regex
(testing "Regex should match valid jwt"
(is (= false (nil? (re-matches clj-jwt/jwtregex example-jwt)))))
(testing "Regex should not match if not a jwt"
(is (nil? (re-matches clj-jwt/jwtregex "ab12356723cdb.1235412513")))))
(deftest refresh-public-keys!
(testing "Repopulates the keystore"
(is (with-redefs [clj-jwt/public-keys (atom {})
clj-jwt/fetch-keys (fn [x] (identity {:foo :bar}))]
(not (empty? (do (clj-jwt/refresh-keys "")
@clj-jwt/public-keys)))))))
(deftest unsign-jwt
(testing "Unsigns jwt and returns payload"
(is (= (with-redefs [clj-jwt/public-keys (atom {"test-key" ec-pubkey})]
(clj-jwt/unsign signed-jwt (resource "jwks.json")))
jwt-payload)))
(testing "Fails if key referenced in jwt header is not found"
(is (thrown? Exception
(with-redefs [clj-jwt/public-keys (atom {})]
(clj-jwt/unsign signed-jwt (resource "jwks-other.json")))))))
(deftest verify-jwt
(testing "Unsigns jwt and returns payload"
(is (= (with-redefs [clj-jwt/public-keys (atom {"test-key" ec-pubkey})]
(clj-jwt/unsign signed-jwt (resource "jwks.json")))
jwt-payload)))
(testing "Refetches keys if no matching keys found"
(is (= (with-redefs [clj-jwt/public-keys (atom {})]
(clj-jwt/unsign signed-jwt (resource "jwks.json")))
jwt-payload)))
(testing "Fails if key referenced in jwt head is not found"
(is (thrown? Exception
(with-redefs [clj-jwt/public-keys (atom {})]
(clj-jwt/unsign signed-jwt (resource "jwks-other.json")))))))
(ns no.nsd.exerciser
(:require [no.nsd.clj-jwt]
[buddy.core.keys :as keys]
[buddy.sign.jwt :as jwt]
[clj-time.core :as time]
[clojure.pprint :as pp]
[clojure.test :as t]
[clojure.spec.alpha :as s]
[clojure.spec.test.alpha :as stest]
[clojure.spec.gen.alpha :as gen]
[clojure.term.colors :refer :all]))
(def private-rsa-key
"-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,27176ED7CC3B4C5F1061DFA491E31FE0
sAxVEIQN6aFka4ApVmag0MO5ZdhEaV0bz1dADLWnBBXRtOdyavYPDJRrzC0TgmZ5
TLUWNEeB60ja1C0iV3TskCOZ6/eMM+/ipISLbYdrz1lJ2UP0Nz+snbi4sNwvU0+t
BTe6ntB9t/cR8n7IaECN5TsaFTrVxKwg5MMXJAEMQKvC5wviNZV+mH/FlYiUG7tr
XzNOdvBkJJO2fn8JD4faQyr8bja5A9Mf0L9Z1ecBX/aGM7AjtqlUXsKbnpWnGhvY
ETLmJ19fWplSKFbsm6olc64/OLZ3m5uXTCXFviUQDZP0duu1+kXzYz2LQSIQYJy8
u4vfiWA4EDgRtbudOv+kdtRMDmY2E1H2zeJKxOpc9Z3khEjtnpoxOV+C3x+pvbfk
Lbaip/LbYujtHt70211GfZCwBBcz8X875DxqxOwg8oalPuakgI18kE+XnQ6IWB1E
NijAi4a52IDdeVsA0LDPFAcnIRed/uIeYXt7FSVfcEAgjk1K7geqa8nQP1TnPO2t
AClIq24BZ14fCpn+apup4mtMaecC8ubOmOs6UmFTZMYqQQECyFgcWx7mCaMJ2iUp
9nJQh+4brIgzIhUSweU7N02W75p+X27tUIKnHTrNIqLRudBisCUGO+oAr0e6YiTd
bM/n5trY7QVwFPiu12YY+fdZWVDa0v7Wz47+V6zBkKDd/HDB5t4zc7B7zZ9RZxs0
Yejev7Z+yRk6aZpBi+6dp/8adS5hVFGnDhKYusi03x0mBBMb1c/IPt72ZcMO5JuW
9IHZoq9ZbYk9UkQLtTgBexfK9np6vqdrTH05+wrskbSk3Gnb8uBUoUMt4AopGnlY
-----END RSA PRIVATE KEY-----")
(def public-rsa-key
"-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDRTHJKynvPn+dzXX2a24hphUFg
olisO68j/ucrXKc7OQ2qPdTzVnmNXNxh+J5UxXRfE3K9hDJSK+SSHMJBPH82jVxK
vLu9XxKFHYlWPccluz3pqDfaGNPO12968DAldwvAV6hTGgx7oMaNPu0UltgD/aaj
6nO5x6zZNuMWb8blMQIDAQAB
-----END PUBLIC KEY-----")
(def ec-privkey (keys/str->private-key private-rsa-key "secret"))
(def ec-pubkey (keys/str->public-key public-rsa-key))
(def sample-claims {:sub "f750bd26-ae85-4808-8f9a-dcc964fc8664"
:exp (time/plus (time/now) (time/minutes 30))})
(def untestable-funs ['no.nsd.clj-jwt/fetch-keys])
(defn generate-jwt
[claims key]
(jwt/sign claims
key
{:alg :rs256
:header {:kid "test-key"}}))
(defn init
[]
;; Redifine key related specs to enable generating
(s/def :no.nsd.clj-jwt/RSAPublicKey (s/with-gen keys/public-key?
#(s/gen #{ec-pubkey})))
(s/def :no.nsd.clj-jwt/jwt (s/with-gen (s/nilable (s/and string?
#(re-matches no.nsd.clj-jwt/jwtregex %)))
#(s/gen #{(generate-jwt sample-claims ec-privkey)})))
;; Stub out functions that will call external resources
(stest/instrument [`no.nsd.clj-jwt/fetch-keys]
{:stub #{`no.nsd.clj-jwt/fetch-keys}}))
(defn result-type
[ret]
(let [failure (:failure ret)]
(cond
(nil? failure) :check-passed
(::s/failure (ex-data failure)) :check-failed
:default :check-threw)))
(defn summarizer
"Simplified version of clojure.spec.tests summarize-results that only prints
errors"
[check-results]
(reduce
(fn [summary result]
(when (:failure result)
(pp/pprint (stest/abbrev-result result)))
(-> summary
(update :total inc)
(update (result-type result) (fnil inc 0))))
{:total 0}
check-results))
(defn exercise-ns
[ns]
(println (bold "Exercising functions in " (name ns)))
(init)
(-> (stest/enumerate-namespace ns)
(#(reduce (fn [fns f] (remove #{f} fns)) % untestable-funs))
(stest/check {:clojure.spec.test.check/opts {:num-tests 300}})
((fn [results]
(doseq [res results]
(println (if (:failure res)
(red (str "x " (:sym res)))
(green (str "✓ " (:sym res))))))
results))
summarizer))
(exercise-ns 'no.nsd.clj-jwt)
(stest/unstrument)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment