Skip to content

Instantly share code, notes, and snippets.

@Dangercoder
Created November 21, 2020 18:42
Show Gist options
  • Save Dangercoder/fe268fab72b9a78386e4ceeeaa97c000 to your computer and use it in GitHub Desktop.
Save Dangercoder/fe268fab72b9a78386e4ceeeaa97c000 to your computer and use it in GitHub Desktop.
Pedestal JWT authentication interceptor
(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