...
 
Commits (46)
......@@ -9,4 +9,7 @@ pom.xml.asc
/.nrepl-port
.hgignore
.hg/
.cpcache
\ No newline at end of file
.cpcache
.dir-locals.el
.idea/
*.iml
\ No newline at end of file
......@@ -16,7 +16,7 @@ after_script:
test:
image: clojure:tools-deps-alpine
image: clojure:openjdk-11-tools-deps-1.10.1.483
script:
- clojure -Atest
- clojure -Apropertytest
- clojure -Adev:test
- clojure -Adev:propertytest
......@@ -2,23 +2,82 @@
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]
## [v0.4.6] - 2020-01-16
### Changed
- Library should stay silent (DEBUG level) by default
- Log error level on failure
## [v0.4.5] - 2020-01-09
### Changed
- Add a new arity to `make-widget-async` to provide a different widget shape.
- Bugfix signing function: Include kid in header by default.
- Support char arrays as jwks-url. This can be used to test without having to use files/URLs.
## [0.1.1] - 2018-09-12
## [v0.4.4] - 2020-01-07
### Changed
- Documentation on how to make the widgets.
- Be slightly more paranoid in scopes function.
### Removed
- `make-widget-sync` - we're all async, all the time.
## [v0.4.3] - 2020-01-07
### Added
- Add scopes function to extract jwt scopes from claims
## [v0.4.2] - 2020-01-07
### Changed
- Support multiple jwks endpoints is supported #3
- Give meaningful error message when jwks-url or token is nil #4
- Handle token starting with `Bearer ` gracefully #5
### Fixed
- Fixed widget maker to keep working when daylight savings switches over.
## [v0.4.1] - 2019-07-30
### Changed
- Use defonce to define keystore atom to prevent accidental redefinitions in upstream project's development
- Update dependencies to latest feature/patch versions
## [v0.4.0] - 2019-06-06
### Added
- New sign function to sign claims and generate JWTs based on private key in JWK
- resolve-public-key function replaces resolve-key function
- resolve-private-key function makes it possible to resolve private keys from JWKS
### Changed
- resolve-key funtion made private as it is now used by resolve-public-key and resolve-private-key
## [v0.3.2] - 2018-11-16
### Changed
- Changed log level from error to info for public key lookup error
## [v0.3.1] - 2018-11-08
### Changed
- Improved logging
## [v0.3.0] - 2018-09-20
### Changed
- Swapped argument order for unsign function to make partial application easier
## [v0.2.1] - 2018-09-20
### Added
- Error logging for failing key resolve
## [v0.2.0] - 2018-09-19
### Added
- Added specs for unsign and generator for ::jwt
- Added logging for retry in resolve-key function
## 0.1.0 - 2018-09-12
## [v0.1.0] - 2018-09-18
### Added
- Files from the new template.
- Widget maker public API - `make-widget-sync`.
- Initial implementation of clj-jwt library.
- Function `resolve-key` that fetches jwks keys and returns a PublicKey given the kid in the jwt header.
- Function `unsign` which tries to validate a jwt given a jwks URL and a jwt.
[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
[Unreleased]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.4.6...HEAD
[v0.4.6]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.4.5...v0.4.6
[v0.4.5]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.4.4...v0.4.5
[v0.4.4]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.4.3...v0.4.4
[v0.4.3]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.4.2...v0.4.3
[v0.4.2]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.4.1...v0.4.2
[v0.4.1]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.4.0...v0.4.1
[v0.4.0]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.3.2...v0.4.0
[v0.3.2]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.3.1...v0.3.2
[v0.3.1]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.3.0...v0.3.1
[v0.3.0]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.2.1...v0.3.0
[v0.2.1]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.2.0...v0.2.1
[v0.2.0]: https://gitlab.nsd.no/clojure/clj-jwt/compare/v0.1.0...v0.2.0
......@@ -2,16 +2,159 @@
![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.
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.
```clojure
[no.nsd/clj-jwt "0.4.5"]
```
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
TBD
### Validating JWTs
You can use the `unsign` function which wraps buddy-sign's own unsign function:
```clojure
(require '[no.nsd.clj-jwt :as clj-jwt])
(clj-jwt/unsign "https://sso-stage.nsd.no/.well-known/jwks.json" "<your-token-here>")
```
Or you can use the `resolve-public-key` function with the jws backend from
buddy-auth:
```clojure
(require '[buddy.auth.backends :as backends])
(require '[no.nsd.clj-jwt :as clj-jwt])
(def auth-backend
(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).
Then clone project and run Clojure Tools Deps targets. If you have rlwrap
installed you can use the `clj` command in place of `clojure`.
Note that you always need to include the `dev` alias when developing as this alias provides all the necessary libraries.
Refer to your editors documentation about how to connect or start a repl integrated with the editor.
```bash
# Run a development clojure repl
clojure -Adev
# Run regular old Clojure tests
clojure -Adev:test
# Exercise clojure specs
clojure -Adev:propertytest
```
You can start a REPL in the project to evaluate code.
For editor integration see [clojure guides - editor integrations](https://gitlab.nsd.no/clojure/guides/blob/master/editor.md).
### Installing 'work in progress' locally
If you are contributing code to the library you may wish to test it against a
clojure project locally to ensure everything works.
You may install your version of clj-jwt into your local m2 repository:
```bash
lein install
```
If you use clojure tools deps you can simply refer to your clj-jwt project in
the other clojure project's `deps.edn` file:
```edn
{:deps
{clj-jwt {:local/root "/path/to/clj-jwt"}}}
```
## Making new release
You need [Leiningen installed](https://leiningen.org/#install). The
`project.clj` file specifies a `snapshot` and `release` repository. You need to
configure credentials for each of the repositories in your
`~/.lein/credentials.clj` file. Example:
```edn
{"https://nexus.nsd.no/repository/maven-snapshots/" {:username "your-nexus-user"
:password "super secret password"}
"https://nexus.nsd.no/repository/maven-releases/" {:username "your-nexus-user"
:password "super secret password"}}
```
To make a release follow these points:
### Run tests and property tests (specs)
```bash
# Run regular old Clojure tests
clojure -Atest
# Exercise clojure specs
clojure -Apropertytest
```
### Bump version number in project.clj
There are different scenarios where you need to increment the version number
differently. The gist of it is that this library follows
[semver](https://semver.org/) with SNAPSHOT releases for test releases.
Making final release from snapshot builds:
0.2.0-SNAPSHOT -> 0.2.0
Making snapshot release from release build:
0.2.0 -> 0.3.0-SNAPSHOT
Making patch release from release build:
0.2.0 -> 0.2.1
Then run the lein deploy command:
### Run leiningen deploy
To build jar and upload to [NSD's Nexus](https://nexus.nsd.no):
```bash
lein deploy
```
### Finally commit, push and tag release
Add a new changelog entry in the `CHANGELOG.md` file.
Commit the project.clj version bump, push it to the Gitlab repository, and tag
it. The tag message should describe the changes made, and the release notes can
link to the release in Nexus.
PS! It is not necessary to commit and push SNAPSHOT releases. SNAPSHOT releases
are mutable and should not be tagged in git.
## License
......@@ -19,3 +162,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"}
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"}
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"}
{:paths ["src" "resources"]
:deps {buddy/buddy-core {:mvn/version "1.6.0"}
buddy/buddy-sign {:mvn/version "3.1.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.5.0"}
invetica/uri {:mvn/version "0.5.0"}}
: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"}}
clojure-term-colors {:mvn/version "0.1.0"}
com.taoensso/timbre {:mvn/version "4.1.0"}
org.slf4j/slf4j-simple {:mvn/version "1.7.30"}}}
: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"}}
: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.1.0-SNAPSHOT"
(defproject no.nsd/clj-jwt "0.4.6"
: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"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:middleware [lein-tools-deps.plugin/resolve-dependencies-with-deps-edn]
:plugins [[lein-tools-deps "0.4.1"]]
:lein-tools-deps/config {:config-files [:project]})
:lein-tools-deps/config {:config-files [:project]}
:repositories [["central" {:url "https://nexus.nsd.no/repository/nsd-maven-public"}]
["snapshots" {:url "https://nexus.nsd.no/repository/nsd-maven-public-snapshots"}]
["releases" {:url "https://nexus.nsd.no/repository/nsd-maven-public-releases"}]])
This diff is collapsed.
......@@ -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
}
......@@ -9,8 +9,14 @@
[clojure.spec.alpha :as s]
[clojure.spec.test.alpha :as stest]
[clojure.spec.gen.alpha :as gen]
[taoensso.timbre :as timbre]
[taoensso.timbre.tools.logging :as cljlog]
[clojure.term.colors :refer :all]))
;; Effectively turn of logging in exercises
(def timbre-config {:level :fatal})
(timbre/merge-config! timbre-config)
(cljlog/use-timbre)
(def private-rsa-key
"-----BEGIN RSA PRIVATE KEY-----
......@@ -45,9 +51,7 @@ vLu9XxKFHYlWPccluz3pqDfaGNPO12968DAldwvAV6hTGgx7oMaNPu0UltgD/aaj
(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
'no.nsd.clj-jwt/resolve-key
'no.nsd.clj-jwt/unsign])
(def untestable-funs ['no.nsd.clj-jwt/fetch-keys])
(defn generate-jwt
[claims key]
......
(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]
[taoensso.timbre :as timbre]
[taoensso.timbre.tools.logging :as cljlog]
[clojure.java.io :refer [resource]]
[clojure.test :refer [deftest testing is]]))
[clojure.test :refer [deftest testing is]])
(:import (java.time ZoneId ZonedDateTime)))
;; Effectively turn of logging in test
(def timbre-config {:level :fatal})
(timbre/merge-config! timbre-config)
(cljlog/use-timbre)
(def example-jwt "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")
......@@ -40,12 +47,11 @@ vLu9XxKFHYlWPccluz3pqDfaGNPO12968DAldwvAV6hTGgx7oMaNPu0UltgD/aaj
(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 jwt-payload {:sub "asd"
:scope "a:read a:write\nb:read "})
(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)))))
......@@ -55,28 +61,73 @@ vLu9XxKFHYlWPccluz3pqDfaGNPO12968DAldwvAV6hTGgx7oMaNPu0UltgD/aaj
(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")))
(is (= (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 {})]
(clj-jwt/unsign signed-jwt (resource "jwks-other.json")))))))
(clj-jwt/unsign (resource "jwks-other.json") signed-jwt)))))
(deftest extract-scope
(testing "Nil input fails early"
(is (thrown? AssertionError
(clj-jwt/scopes nil))))
(testing "Missing scope gives empty set"
(is (= #{} (->> (buddy-jwt/sign {:sub "jalla" :scope ""} ec-privkey {:alg :rs256 :header {:kid "test-key"}})
(clj-jwt/unsign (resource "jwks.json"))
(clj-jwt/scopes))))
(is (= #{} (->> (buddy-jwt/sign {:sub "jalla" :scope " "} ec-privkey {:alg :rs256 :header {:kid "test-key"}})
(clj-jwt/unsign (resource "jwks.json"))
(clj-jwt/scopes))))
(is (= #{} (->> (buddy-jwt/sign {:sub "jalla"} ec-privkey {:alg :rs256 :header {:kid "test-key"}})
(clj-jwt/unsign (resource "jwks.json"))
(clj-jwt/scopes)))))
(testing "Scope extraction"
(is (= (-> (clj-jwt/unsign (resource "jwks.json") signed-jwt)
(clj-jwt/scopes))
#{"a:read" "b:read" "a:write"}))))
(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")))
(is (= (clj-jwt/unsign (resource "jwks.json") signed-jwt)
jwt-payload)))
(testing "Unsign supports char arrays as key"
(is (= (clj-jwt/unsign (char-array (slurp (resource "jwks.json"))) signed-jwt)
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")))
(is (= (clj-jwt/unsign (resource "jwks.json") signed-jwt)
jwt-payload)))
(testing "Verify token is not expired"
(let [payload {:sub "jalla" :exp (-> (ZonedDateTime/now (ZoneId/of "UTC")) (.plusHours 1) (.toEpochSecond))}]
(is (= (->> payload
(clj-jwt/sign (resource "jwks.json") "test-key")
(clj-jwt/unsign (resource "jwks.json")))
payload))))
(testing "Verify token is expired"
(let [payload {:sub "jalla" :exp (-> (ZonedDateTime/now (ZoneId/of "UTC")) (.minusHours 1) (.toEpochSecond))}]
(is (thrown? Exception (->> payload
(clj-jwt/sign (resource "jwks.json") "test-key")
(clj-jwt/unsign (resource "jwks.json")))))))
(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")))))))
(clj-jwt/unsign (resource "jwks-other.json") signed-jwt)))))
(deftest sign-claims
(testing "Signs claims and return a valid jwt"
(is (= (clj-jwt/sign (resource "jwks.json") "test-key" {:sub "foo"})
"eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJzdWIiOiJmb28ifQ.pn9YAwHb4FhEksaH9keRA9lgPh01RkkzR44u0wqDJjbXROSygCr6Ry4mT7WuGhY9ha0tBVfriN29pfnZgPiIgI3Z1xue4nMdHnveYo985xvwkW8PIP1yjbshfARscO2SdTm_odyKh-CZzpLiihfM3kpYmFhpL8-pzRLZPSnc3Jg")))
(testing "Sign claims using char array as jwks"
(is (= (clj-jwt/sign (char-array (slurp (resource "jwks.json"))) "test-key" {:sub "foo"})
"eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJzdWIiOiJmb28ifQ.pn9YAwHb4FhEksaH9keRA9lgPh01RkkzR44u0wqDJjbXROSygCr6Ry4mT7WuGhY9ha0tBVfriN29pfnZgPiIgI3Z1xue4nMdHnveYo985xvwkW8PIP1yjbshfARscO2SdTm_odyKh-CZzpLiihfM3kpYmFhpL8-pzRLZPSnc3Jg")))
(testing "Verify round trip"
(is (= (clj-jwt/unsign (resource "jwks.json") (clj-jwt/sign (resource "jwks.json") "test-key" {:sub "foo"}))
{:sub "foo"})))
(testing "Fails if given key-id not in jwks resource"
(is (thrown? Exception (clj-jwt/sign (resource "jwks.json") "no-such-key" {:sub "foo"})))))