Add signing and private key support

The library now adds wrappers around the buddy library's sign function. This is
made possible by extracting any private keys from the json web key list.

Specs are added for the new private key functionality.

The resolve-key function is now private, and new functions resolve-public-key
and resolve-private-key has taken its place.
Signed-off-by: Snorre Magnus Davøen's avatarSnorre Magnus Davøen <snorre.davoen@nsd.no>
parent 920e4905
Pipeline #19299 failed with stage
in 29 seconds
......@@ -9,4 +9,5 @@ pom.xml.asc
/.nrepl-port
.hgignore
.hg/
.cpcache
\ No newline at end of file
.cpcache
.dir-locals.el
\ No newline at end of file
......@@ -2,19 +2,20 @@
![clj-jwt logo](./clj-jwt.png)
A Clojure library to handle validation of JWTs.
A Clojure library to handle validation of JWTs and signing claims using JSON Web Keys.
```clojure
[no.nsd/clj-jwt "0.3.2"]
[no.nsd/clj-jwt "0.4.0-SNAPSHOT"]
```
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.
clj-jwt wraps some of [Buddy's](https://funcool.github.io/buddy-sign/latest/) functions for validating JWTs and signing claims.
It uses a JWKS endpoint to fetch the public or private key to use for validation or signing respectively.
By using this library you can abstract away key handling as the library will automatically fetch new keys as the JWK server issues new keys.
## Usage
### Validating JWTs
You can use the `unsign` function which wraps buddy-sign's own unsign function:
```clojure
......@@ -23,7 +24,7 @@ You can use the `unsign` function which wraps buddy-sign's own unsign function:
(clj-jwt/unsign "https://sso-stage.nsd.no/.well-known/jwks.json" "<your-token-here>")
```
Or you can use the `resolve-key` function with the jws backend from
Or you can use the `resolve-public-key` function with the jws backend from
buddy-auth:
```clojure
......@@ -31,13 +32,24 @@ buddy-auth:
(require '[no.nsd.clj-jwt :as clj-jwt])
(def auth-backend
(backends/jws {:secret (partial clj-jwt/resolve-key "https://sso-stage.nsd.no/.well-known/jwks.json")
(backends/jws {:secret (partial clj-jwt/resolve-public-key "https://sso-stage.nsd.no/.well-known/jwks.json")
:token-name "Bearer"
:authfn (fn [claims] claims)
:on-error (fn [request err] nil)
:options {:alg :rs256}}))
```
### Signing claims (creating tokens)
You can sign your own tokens if your JSON web token contains a private key component.
The `sign` function expects a jwks URL/path, a key id, the claims to sign, and optionally options to the buddy sign function.
```clojure
(require '[no.nsd.clj-jwt :as clj-jwt])
(clj-jwt/sign "my-local-jwks.json" "my-jwk-kid" {:sub "some-user"})
```
## Development
Ensure you have [Clojure installed](https://clojure.org/guides/getting_started).
......@@ -46,10 +58,10 @@ installed you can use the `clj` command in place of `clojure`.
```bash
# Run regular old Clojure tests
clojure -Atest
clojure -Adev:test
# Exercise clojure specs
clojure -Apropertytest
clojure -Adev:propertytest
```
You can start a REPL in the project to evaluate code. If you need an nREPL configure
......@@ -63,7 +75,7 @@ a global tools deps alias in `~/.clojure/deps.edn`:
Then run:
```bash
clojure -AnREPL
clojure -Adev:nREPL
```
### Installing 'work in progress' locally
......@@ -154,3 +166,4 @@ 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.
{:paths ["src" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.9.0"}
buddy/buddy-core {:mvn/version "1.5.0"}
:deps {buddy/buddy-core {:mvn/version "1.5.0"}
buddy/buddy-sign {:mvn/version "3.0.0"}
org.clojure/data.json {:mvn/version "0.2.6"}
org.clojure/algo.generic {:mvn/version "0.1.3"}
org.clojure/tools.logging {:mvn/version "0.4.1"}
invetica/uri {:mvn/version "0.5.0"}}
:aliases {:test {:extra-paths ["test" "test-resources"]
:extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git"
:sha "028a6d41ac9ac5d5c405dfc38e4da6b4cc1255d5"}
:aliases {:dev {:extra-paths ["test" "test-resources"]
:extra-deps {org.clojure/clojure {:mvn/version "1.10.0"}
org.clojure/test.check {:mvn/version "0.9.0"}
clj-time {:mvn/version "0.14.4"}
clojure-term-colors {:mvn/version "0.1.0"}
com.taoensso/timbre {:mvn/version "4.1.0"}}
com.taoensso/timbre {:mvn/version "4.1.0"}}}
:test {:extra-paths ["test" "test-resources"]
:extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git"
:sha "028a6d41ac9ac5d5c405dfc38e4da6b4cc1255d5"}}
:main-opts ["-m cognitect.test-runner"]}
:propertytest {:extra-deps {org.clojure/test.check {:mvn/version "0.9.0"}
clj-time {:mvn/version "0.14.4"}
clojure-term-colors {:mvn/version "0.1.0"}
com.taoensso/timbre {:mvn/version "4.1.0"}}
:extra-paths ["test" "test-resources"]
:main-opts ["-m exerciser"]}
:jar {:extra-deps {luchiniatwork/cambada {:mvn/version "1.0.0"}}
:main-opts ["-m" "cambada.jar"]}}
:propertytest {:main-opts ["-m exerciser"]}}
:mvn/repos {"clojars" {:url "https://nexus.nsd.no/repository/nsd-maven-public/"}
"central" {:url "https://nexus.nsd.no/repository/nsd-maven-public/"}}}
(defproject no.nsd/clj-jwt "0.3.2"
(defproject no.nsd/clj-jwt "0.4.0-SNAPSHOT"
:description "A Clojure library to fetch json web keys and validate json web tokens. Wraps Buddy."
:url "https://gitlab.nsd.no/clojure/clj-jwt"
:license {:name "Eclipse Public License"
......
......@@ -10,13 +10,12 @@
[clojure.tools.logging :as log]
[invetica.uri :as uri]))
(def jwtregex #"^[a-zA-Z0-9\-_=]+?\.[a-zA-Z0-9\-_=]+?\.[a-zA-Z0-9\-_=]+?$")
(s/def ::sub (s/nilable string?))
(s/def ::kid (s/with-gen (s/nilable string?)
#(s/gen #{"test-key"})))
#(s/gen #{"test-key"})))
(s/def ::scope (s/nilable string?))
......@@ -29,12 +28,20 @@
#(= "RSA" %)))
#(s/gen #{"RSA" nil})))
;; n is the public key component of a json web key
(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"})))
;; d is the private key component of a json web key
(s/def ::d (s/with-gen string?
#(s/gen #{;; Valid:
"PJrXSYLiYRebbJN4yHujP3LfoHzCEnVh3Jl2FN9KaWK260HmROQYZG-sPQ5Bwqg-bz1xbyE1dQfSsuBy-3LqHrqM-ilsvcNZqQEY9R52d9D6kXmTSNMHx-3jGQ0SeO0eIFMHffLHOomvECPEKZkSPB65rijLcKQKmbnA_OlF_EE"
;; Invalid:
"xnmcbvjksdhfwiuerfsdjbsdkjfghwileugkhjbvnxdvjbvwiuerhslkdjbvvklwl4iuhjxcvxnmbvkwerjlfhiwuerhsjdkdfkjbvwe4riefslkv-dlsfkjhwpoiefhcvsdjkhvowpefwoeifhv_sdøflkhjwpeoifhsvøkl"})))
(s/def ::e string?)
(s/def ::claims (s/keys :opt-un [::exp
......@@ -51,10 +58,15 @@
(s/def ::jwk (s/keys :req-un [::kty ::e ::n ::kid]))
(s/def ::RSAPublicKey keys/public-key?)
(s/def ::public-key keys/public-key?)
(s/def ::private-key keys/private-key?)
(s/def ::key (s/keys :req-un [::public-key]
:opt-un [::private-key]))
(s/def ::key-store (s/map-of ::kid
::RSAPublicKey))
::key))
(s/def ::resource (s/with-gen #(instance? java.net.URL %)
;; Always use local resources to avoid spamming actual servers
......@@ -62,25 +74,27 @@
(resource "jwks-other.json")})))
(s/def ::jwks-url (s/with-gen (s/or :url :invetica.uri/absolute-uri
:resource ::resource)
;; Always use local resources to avoid spamming actual servers
#(s/gen #{(resource "jwks.json")
(resource "jwks-other.json")})))
:resource ::resource)
;; Always use local resources to avoid spamming actual servers
#(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)
(s/fdef jwks-edn->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."
(defn- jwks-edn->keys
"Transform a vector of json web keys into a map of kid -> key pairs where each key is a map
of :public-key and optionally :private-keys."
[json-web-keys]
(->> json-web-keys
:keys
(filter #(= (:kty %) "RSA"))
(group-by :kid)
(fmap first)
(fmap buddy-jwk/jwk->public-key)))
(fmap #(assoc {}
:public-key (buddy-jwk/jwk->public-key %)
:private-key (buddy-jwk/jwk->private-key %)))))
(s/fdef fetch-keys
:args (s/cat :jwks-url ::jwks-url)
......@@ -88,8 +102,7 @@
#(s/gen #{(->> (resource "jwks.json")
slurp
((fn [jwks-string] (json/read-str jwks-string :key-fn keyword)))
jwks-edn->public-keys)})))
jwks-edn->keys)})))
(defn- fetch-keys
"Fetches the jwks from the supplied jwks-url and converts to java Keys.
......@@ -99,43 +112,59 @@
(try (->> jwks-url
slurp
(#(json/read-str % :key-fn keyword))
jwks-edn->public-keys)
jwks-edn->keys)
(catch Exception e (do (log/error "Could not fetch jwks keys")
false))))
(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 {}))
(def keystore
"Atom to hold the public and private keys used for signature validation in memory for
caching purposes. The atom holds a clojure map with kid -> key pairs. Each key is a
clojure map containing a :public-key and optionally a :private-key."
(atom {}))
(s/fdef resolve-key
:args (s/cat :jwks-url ::jwks-url
:jwt-header ::jwt-header)
:ret ::RSAPublicKey)
(defn resolve-key
"Returns java.security.PublicKey given jwks-url and :kid in jwt-header.
(defn- resolve-key
"Returns java.security.Key given key-fn, jwks-url and :key-type in jwt-header.
If no key is found refreshes"
[jwks-url jwt-header]
[key-type jwks-url jwt-header]
(log/debug "Resolving key" jwt-header "from jwk cache for" jwks-url)
(let [key-fn (fn [] (get @public-keys (:kid jwt-header)))]
(let [key-fn (fn [] (get-in @keystore [(:kid jwt-header) key-type]))]
(if-let [key (key-fn)]
key
(do (log/info "Fetch and resolve key" jwt-header "from" jwks-url)
(reset! public-keys (or (fetch-keys jwks-url) @public-keys))
(reset! keystore (or (fetch-keys jwks-url) @keystore))
(if-let [key (key-fn)]
key
(do
(log/info "Could not locate public key corresponding to jwt header's kid: " (:kid jwt-header))
(throw (ex-info (str "Could not locate public key corresponding to jwt header's kid: " (:kid jwt-header))
(throw (ex-info (str "Could not locate key corresponding to jwt header's kid: " (:kid jwt-header))
{:type :validation :cause :unknown-key}))))))))
(s/fdef resolve-public-key
:args (s/cat :jwks-url ::jwks-url
:jwt-header ::jwt-header)
:ret ::public-key)
(def resolve-public-key
"Returns java.security.PublicKey given jwks-url and :kid in jwt-header.
If no key is found refreshes"
(partial resolve-key :public-key))
(s/fdef resolve-private-key
:args (s/cat :jwks-url ::jwks-url
:jwt-header ::jwt-header)
:ret ::private-key)
(def resolve-private-key
(partial resolve-key :private-key))
(s/fdef unsign
:args (s/cat :jwks-url ::jwks-url
:token ::jwt)
:ret ::claims)
:args (s/cat :jwks-url ::jwks-url
:token ::jwt)
:ret ::claims)
(defn unsign
"Given jwks-url, token, and optionally opts validates and returns the claims
......@@ -143,4 +172,19 @@
([jwks-url token]
(unsign jwks-url token {}))
([jwks-url token opts]
(jwt/unsign token (partial resolve-key jwks-url) (merge {:alg :rs256} opts))))
(jwt/unsign token (partial resolve-public-key jwks-url) (merge {:alg :rs256} opts))))
(s/fdef sign
:args (s/cat :jwks-url ::jwks-url
:kid ::kid
:claims ::claims)
:ret ::jwt)
(defn sign
"Given jwks-url, claims and optionally opts signs claims and returns a token. Uses
the private key in the jwks to sign. Opts are the same as buddy-sign.jwt/sign."
([jwks-url kid claims]
(sign jwks-url kid claims {}))
([jwks-url kid claims options]
(jwt/sign claims (resolve-private-key jwks-url {:kid kid}) (merge {:alg :rs256} options))))
......@@ -3,6 +3,7 @@
"kid": "test-key",
"kty": "RSA",
"e": "AQAB",
"n": "0UxySsp7z5_nc119mtuIaYVBYKJYrDuvI_7nK1ynOzkNqj3U81Z5jVzcYfieVMV0XxNyvYQyUivkkhzCQTx_No1cSry7vV8ShR2JVj3HJbs96ag32hjTztdvevAwJXcLwFeoUxoMe6DGjT7tFJbYA_2mo-pzuces2TbjFm_G5TE"
"n": "0UxySsp7z5_nc119mtuIaYVBYKJYrDuvI_7nK1ynOzkNqj3U81Z5jVzcYfieVMV0XxNyvYQyUivkkhzCQTx_No1cSry7vV8ShR2JVj3HJbs96ag32hjTztdvevAwJXcLwFeoUxoMe6DGjT7tFJbYA_2mo-pzuces2TbjFm_G5TE",
"d": "PJrXSYLiYRebbJN4yHujP3LfoHzCEnVh3Jl2FN9KaWK260HmROQYZG-sPQ5Bwqg-bz1xbyE1dQfSsuBy-3LqHrqM-ilsvcNZqQEY9R52d9D6kXmTSNMHx-3jGQ0SeO0eIFMHffLHOomvECPEKZkSPB65rijLcKQKmbnA_OlF_EE"
}]
}
\ No newline at end of file
}
......@@ -51,8 +51,6 @@ vLu9XxKFHYlWPccluz3pqDfaGNPO12968DAldwvAV6hTGgx7oMaNPu0UltgD/aaj
(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)))))
......@@ -62,28 +60,28 @@ vLu9XxKFHYlWPccluz3pqDfaGNPO12968DAldwvAV6hTGgx7oMaNPu0UltgD/aaj
(deftest unsign-jwt
(testing "Unsigns jwt and returns payload"
(is (= (with-redefs [clj-jwt/public-keys (atom {"test-key" ec-pubkey})]
(is (= (with-redefs [clj-jwt/keystore (atom {"test-key" {:public-key ec-pubkey}})]
(clj-jwt/unsign (resource "jwks.json") signed-jwt))
jwt-payload)))
(testing "Fails if key referenced in jwt header is not found"
(is (thrown? Exception
(with-redefs [clj-jwt/public-keys (atom {})]
(with-redefs [clj-jwt/keystore (atom {})]
(clj-jwt/unsign (resource "jwks-other.json") signed-jwt))))))
(deftest verify-jwt
(testing "Unsigns jwt and returns payload"
(is (= (with-redefs [clj-jwt/public-keys (atom {"test-key" ec-pubkey})]
(is (= (with-redefs [clj-jwt/keystore (atom {"test-key" {:public-key ec-pubkey}})]
(clj-jwt/unsign (resource "jwks.json") signed-jwt))
jwt-payload)))
(testing "Refetches keys if no matching keys found"
(is (= (with-redefs [clj-jwt/public-keys (atom {})]
(is (= (with-redefs [clj-jwt/keystore (atom {})]
(clj-jwt/unsign (resource "jwks.json") signed-jwt))
jwt-payload)))
(testing "Fails if key referenced in jwt head is not found"
(is (thrown? Exception
(with-redefs [clj-jwt/public-keys (atom {})]
(with-redefs [clj-jwt/keystore (atom {})]
(clj-jwt/unsign (resource "jwks-other.json") signed-jwt))))))
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