Created
November 21, 2020 18:42
-
-
Save Dangercoder/fe268fab72b9a78386e4ceeeaa97c000 to your computer and use it in GitHub Desktop.
Pedestal JWT authentication interceptor
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns interceptors.auth | |
(:require [cheshire.core :as json] | |
[clojure.spec.alpha :as s] | |
[clojure.walk :refer [postwalk]] | |
[clojure.core.async :as a]) | |
(:import (java.net URL) | |
(com.auth0.jwk GuavaCachedJwkProvider UrlJwkProvider) | |
(com.auth0.jwt.interfaces RSAKeyProvider ECDSAKeyProvider) | |
(com.auth0.jwt.algorithms Algorithm) | |
(com.auth0.jwt JWT) | |
(com.auth0.jwt.exceptions JWTDecodeException SignatureVerificationException AlgorithmMismatchException TokenExpiredException JWTVerificationException) | |
(org.apache.commons.codec Charsets) | |
(org.apache.commons.codec.binary Base64) | |
(java.security PublicKey))) | |
(defn- new-jwk-provider | |
[url] | |
(-> (URL. url) | |
(UrlJwkProvider.) | |
(GuavaCachedJwkProvider.))) | |
(def ^{:private true} rsa-key-provider | |
(memoize | |
(fn [url] | |
(let [jwk-provider (new-jwk-provider url)] | |
(reify RSAKeyProvider | |
(getPublicKeyById [_ key-id] | |
(-> (.get jwk-provider key-id) | |
(.getPublicKey))) | |
(getPrivateKey [_] nil) | |
(getPrivateKeyId [_] nil)))))) | |
(defn- keywordize-non-namespaced-claims | |
"Walks through the claims keywordizing them unless the key is namespaced. This is detected | |
by virtue of checking for the presence of a '/' in the key name." | |
[m] | |
(let [namespaced? #(clojure.string/includes? % "/") | |
keywordize-pair (fn [[k v]] | |
[(if (and (string? k) (not (namespaced? k))) | |
(keyword k) k) | |
v])] | |
(postwalk #(cond-> % (map? %) (->> (map keywordize-pair) | |
(into {}))) | |
m))) | |
(defn- base64->map | |
[base64-str] | |
(-> base64-str | |
(Base64/decodeBase64) | |
(String. Charsets/UTF_8) | |
(json/parse-string) | |
(keywordize-non-namespaced-claims))) | |
(defn- decode-token* | |
[algorithm token {:keys [issuer leeway-seconds]}] | |
(let [add-issuer #(if issuer | |
(.withIssuer % (into-array String [issuer])) | |
%)] | |
(-> algorithm | |
(JWT/require) | |
(.acceptLeeway (or leeway-seconds 0)) | |
add-issuer | |
(.build) | |
(.verify token) | |
(.getPayload) | |
(base64->map)))) | |
(s/def ::alg #{:RS256 :HS256 :ES256}) | |
(s/def ::issuer (s/and string? (complement clojure.string/blank?))) | |
(s/def ::leeway-seconds nat-int?) | |
(s/def ::secret (s/and string? (complement clojure.string/blank?))) | |
(s/def ::secret-opts (s/and (s/keys :req-un [::alg ::secret]) | |
#(contains? #{:HS256} (:alg %)))) | |
(s/def ::public-key #(instance? PublicKey %)) | |
(s/def ::jwk-endpoint (s/and string? #(re-matches #"(?i)^https?://.+$" %))) | |
(s/def ::public-key-opts (s/and #(contains? #{:RS256 :ES256} (:alg %)) | |
(s/or :key (s/keys :req-un [::alg ::public-key]) | |
:url (s/keys :req-un [::alg ::jwk-endpoint])))) | |
(defmulti ^{:private true} decode | |
"Decodes and verifies the signature of the given JWT token. The decoded claims from the token are returned." | |
(fn [_ {:keys [alg]}] alg)) | |
(defmethod decode nil | |
[& _] | |
(throw (JWTDecodeException. "Could not parse algorithm."))) | |
(defmethod decode :ES256 | |
[token {:keys [public-key] :as opts}] | |
{:pre [(s/valid? ::public-key-opts opts)]} | |
(let [[public-key-type _] (s/conform ::public-key-opts opts)] | |
(assert (= :key public-key-type)) | |
(-> (Algorithm/ECDSA256 public-key) | |
(decode-token* token opts)))) | |
(defmethod decode :RS256 | |
[token {:keys [public-key jwk-endpoint] :as opts}] | |
{:pre [(s/valid? ::public-key-opts opts)]} | |
(let [[public-key-type _] (s/conform ::public-key-opts opts)] | |
(-> (Algorithm/RSA256 (case public-key-type | |
:url (rsa-key-provider jwk-endpoint) | |
:key public-key)) | |
(decode-token* token opts)))) | |
(defmethod decode :HS256 | |
[token {:keys [secret] :as opts}] | |
{:pre [(s/valid? ::secret-opts opts)]} | |
(-> (Algorithm/HMAC256 secret) | |
(decode-token* token opts))) | |
(defn ^{:private true} find-token* | |
[{:keys [request]}] | |
(some->> (:headers request) | |
(filter #(.equalsIgnoreCase "authorization" (key %))) | |
(first) | |
(val) | |
(re-find #"(?i)^Bearer (.+)$") | |
(last))) | |
(s/def ::alg-opts (s/and (s/keys :req-un [::alg] | |
:opt-un [::leeway-seconds ::issuer]) | |
(s/or :secret-opts ::secret-opts | |
:public-key-opts ::public-key-opts))) | |
(defn jwt-interceptor | |
"Interceptor that decodes a JWT token, verifies against the signature and then | |
adds the decoded claims to the incoming request under :claims. | |
If the JWT token exists but cannot be decoded then the token is considered tampered with and | |
a 401 response is produced. | |
If the JWT token does not exist, an empty :claims map is added to the incoming request." | |
[{:keys [find-token-fn] :as opts}] | |
(when (not (s/valid? ::alg-opts opts)) | |
(throw (ex-info "Invalid options." (s/explain-data ::alg-opts opts)))) | |
{:enter (fn [ctx] | |
(a/go | |
(try | |
(if-let [token ((or find-token-fn find-token*) ctx)] | |
(->> (decode token opts) | |
(assoc ctx :claims)) | |
(->> (assoc ctx :claims {}))) | |
(catch SignatureVerificationException _ | |
(assoc ctx :response {:status 401 | |
:headers {} | |
:body "Signature could not be verified."})) | |
(catch AlgorithmMismatchException _ | |
(assoc ctx :response {:status 401 | |
:headers {} | |
:body "Signature could not be verified."})) | |
(catch TokenExpiredException _ | |
(assoc ctx :response {:status 401 | |
:headers {} | |
:body "Token has expired."})) | |
(catch JWTVerificationException _ | |
(assoc ctx :response {:status 401 | |
:body "One or more claims were invalid."})))))}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment