Skip to content

Commit

Permalink
Implement API authentication and authorization.
Browse files Browse the repository at this point in the history
  • Loading branch information
codonnell committed Apr 8, 2020
1 parent b75318c commit c48056b
Show file tree
Hide file tree
Showing 13 changed files with 358 additions and 40 deletions.
38 changes: 20 additions & 18 deletions deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
ring/ring-core {:mvn/version "1.8.0"}
ring/ring-defaults {:mvn/version "0.3.2"}
bk/ring-gzip {:mvn/version "0.3.0"}
com.auth0/java-jwt {:mvn/version "3.10.2"}
com.auth0/jwks-rsa {:mvn/version "0.11.0"}
integrant {:mvn/version "0.8.0"}
seancorfield/next.jdbc {:mvn/version "1.0.409"}
honeysql {:mvn/version "0.9.10"}
Expand All @@ -15,21 +17,21 @@
hikari-cp {:mvn/version "2.11.0"}
aero {:mvn/version "1.1.6"}
http-kit {:mvn/version "2.4.0-alpha6"}}
:aliases {:dev {:extra-paths ["dev"]
:jvm-opts ["-Dtrace"]
:extra-deps {org.clojure/tools.namespace {:mvn/version "1.0.0"}
org.clojure/clojurescript {:mvn/version "1.10.597"}
com.fulcrologic/semantic-ui-wrapper {:mvn/version "1.0.0"}
org.clojure/core.async {:mvn/version "1.1.587"}
com.cognitect/transit-cljs {:mvn/version "0.8.256"}
com.wsscode/async {:mvn/version "1.0.3"}
clj-commons/pushy {:mvn/version "0.3.10"}
thheller/shadow-cljs {:mvn/version "2.8.94"}
binaryage/devtools {:mvn/version "1.0.0"}
integrant/repl {:mvn/version "0.3.1"}}}
:test {:extra-paths ["test"]
:extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git"
:sha "209b64504cb3bd3b99ecfec7937b358a879f55c1"}}}
:run-tests {:main-opts ["-m" "cognitect.test-runner"]}
:outdated {:extra-deps {olical/depot {:mvn/version "1.8.4"}}
:main-opts ["-m" "depot.outdated.main"]}}}
:aliases {:dev {:extra-paths ["dev"]
:jvm-opts ["-Dtrace"]
:extra-deps {org.clojure/tools.namespace {:mvn/version "1.0.0"}
org.clojure/clojurescript {:mvn/version "1.10.597"}
com.fulcrologic/semantic-ui-wrapper {:mvn/version "1.0.0"}
org.clojure/core.async {:mvn/version "1.1.587"}
com.cognitect/transit-cljs {:mvn/version "0.8.256"}
com.wsscode/async {:mvn/version "1.0.3"}
clj-commons/pushy {:mvn/version "0.3.10"}
thheller/shadow-cljs {:mvn/version "2.8.94"}
binaryage/devtools {:mvn/version "1.0.0"}
integrant/repl {:mvn/version "0.3.1"}}}
:test {:extra-paths ["test"]
:extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git"
:sha "209b64504cb3bd3b99ecfec7937b358a879f55c1"}}}
:run-tests {:main-opts ["-m" "cognitect.test-runner"]}
:outdated {:extra-deps {olical/depot {:mvn/version "1.8.4"}}
:main-opts ["-m" "depot.outdated.main"]}}}
1 change: 1 addition & 0 deletions dev/user.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
rocks.mygiftlist.parser
rocks.mygiftlist.db
rocks.mygiftlist.config
rocks.mygiftlist.authentication
[integrant.core :as ig]
[integrant.repl :refer [clear go halt prep init reset reset-all]]
[integrant.repl.state :refer [system]]
Expand Down
5 changes: 4 additions & 1 deletion resources/config.edn
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@
:sslmode #or [#env POSTGRES_SSLMODE "disable"]}
:port #long #profile {:dev 3000
:test 3001
:prod #env PORT}}
:prod #env PORT}
:auth {:jwk-endpoint #or [#env JWK_ENDPOINT "https://mygiftlist-blog.auth0.com/.well-known/jwks.json"]
:issuer #or [#env JWT_ISSUER "https://mygiftlist-blog.auth0.com/"]
:audience #or [#env JWT_AUDIENCE "https://blog.mygiftlist.rocks"]}}
4 changes: 4 additions & 0 deletions resources/system.edn
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{:rocks.mygiftlist.config/config
{:rocks.mygiftlist.config/profile :prod}

:rocks.mygiftlist.authentication/wrap-jwt
{:rocks.mygiftlist.config/config #ig/ref :rocks.mygiftlist.config/config}

:rocks.mygiftlist.db/pool
{:rocks.mygiftlist.config/config #ig/ref :rocks.mygiftlist.config/config}

Expand All @@ -9,4 +12,5 @@

:rocks.mygiftlist.server/server
{:rocks.mygiftlist.parser/parser #ig/ref :rocks.mygiftlist.parser/parser
:rocks.mygiftlist.authentication/wrap-jwt #ig/ref :rocks.mygiftlist.authentication/wrap-jwt
:rocks.mygiftlist.config/config #ig/ref :rocks.mygiftlist.config/config}}
5 changes: 3 additions & 2 deletions src/rocks/mygiftlist/application.cljs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
(ns rocks.mygiftlist.application
(:require [com.fulcrologic.fulcro.application :as app]
[com.fulcrologic.fulcro.rendering.keyframe-render2 :as keyframe-render2]
[com.fulcrologic.fulcro.networking.http-remote :as http-remote]
[com.fulcrologic.fulcro.networking.http-remote :as f.http-remote]
[rocks.mygiftlist.http-remote :as http-remote]
[rocks.mygiftlist.transit :as transit]))

(defonce SPA
Expand All @@ -12,5 +13,5 @@
(http-remote/wrap-fulcro-request
identity transit/write-handlers)
:response-middleware
(http-remote/wrap-fulcro-response
(f.http-remote/wrap-fulcro-response
identity transit/read-handlers)})}}))
75 changes: 75 additions & 0 deletions src/rocks/mygiftlist/authentication.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
(ns rocks.mygiftlist.authentication
(:require [rocks.mygiftlist.config :as config]
[integrant.core :as ig])
(:import [java.net URL]
[java.time Instant]
[com.auth0.jwk GuavaCachedJwkProvider UrlJwkProvider]
[com.auth0.jwt.interfaces RSAKeyProvider]
[com.auth0.jwt JWT]
[com.auth0.jwt.algorithms Algorithm]
[com.auth0.jwt.exceptions JWTVerificationException]))

(defn create-key-provider [url]
(let [provider (-> url
(URL.)
(UrlJwkProvider.)
(GuavaCachedJwkProvider.))]
(reify RSAKeyProvider
(getPublicKeyById [_ key-id]
(-> provider
(.get key-id)
(.getPublicKey)))
(getPrivateKey [_] nil)
(getPrivateKeyId [_] nil))))

(defn verify-token
"Given a key-provider created by `create-key-provider`, an issuer,
an audience, and a jwt, decodes the jwt and returns it if the jwt is
valid. Returns nil if the jwt is invalid."
[key-provider {:keys [issuer audience]} token]
(let [algorithm (Algorithm/RSA256 key-provider)
verifier (-> algorithm
(JWT/require)
(.withIssuer (into-array String [issuer]))
(.withAudience (into-array String [audience]))
(.build))]
(try
(let [decoded-jwt (.verify verifier token)]
{:iss (.getIssuer decoded-jwt)
:sub (.getSubject decoded-jwt)
:aud (vec (.getAudience decoded-jwt))
:iat (.toInstant (.getIssuedAt decoded-jwt))
:exp (.toInstant (.getExpiresAt decoded-jwt))
:azp (.asString (.getClaim decoded-jwt "azp"))
:scope (.asString (.getClaim decoded-jwt "scope"))})
(catch JWTVerificationException e
nil))))

(defn- get-token [req]
(when-let [header (get-in req [:headers "authorization"])]
(second (re-find #"^Bearer (.+)" header))))

(defn wrap-jwt
"Middleware that verifies and adds claim data to a request based on
a bearer token in the header.
If a bearer token is found in the authorization header, attempts to
verify it. If verification succeeds, adds the token's claims to the
request under the `::claims` key. If verification fails, leaves the
request unchanged."
[handler key-provider expected-claims]
(fn [req]
(let [token (get-token req)
claims (when token
(verify-token key-provider expected-claims token))]
(handler (cond-> req
claims (assoc ::claims claims))))))

(defmethod ig/init-key ::wrap-jwt
[_ {::config/keys [config]}]
(fn [handler]
(wrap-jwt handler
(create-key-provider
(config/jwk-endpoint config))
{:issuer (config/jwt-issuer config)
:audience (config/jwt-audience config)})))
9 changes: 9 additions & 0 deletions src/rocks/mygiftlist/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,12 @@

(defn port [config]
(:port config))

(defn jwk-endpoint [config]
(get-in config [:auth :jwk-endpoint]))

(defn jwt-issuer [config]
(get-in config [:auth :issuer]))

(defn jwt-audience [config]
(get-in config [:auth :audience]))
165 changes: 165 additions & 0 deletions src/rocks/mygiftlist/http_remote.cljs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
(ns rocks.mygiftlist.http-remote
(:refer-clojure :exclude [send])
(:require
[clojure.string :as str]
[cognitect.transit :as ct]
[com.fulcrologic.fulcro.algorithms.transit :as t]
[com.fulcrologic.fulcro.algorithms.tx-processing :as txn]
[com.fulcrologic.fulcro.networking.http-remote :as f.http]
[clojure.core.async :refer [go <!]]
[com.wsscode.async.async-cljs :refer [let-chan]]
[edn-query-language.core :as eql]
[goog.events :as events]
[taoensso.timbre :as log]
[rocks.mygiftlist.authentication :as auth])
(:import [goog.net XhrIo EventType ErrorCode]))

(defn wrap-fulcro-request
([handler addl-transit-handlers transit-transformation]
(let [writer (t/writer (cond-> {}
addl-transit-handlers
(assoc :handlers addl-transit-handlers)

transit-transformation
(assoc :transform transit-transformation)))]
(fn [{:keys [headers body] :as request}]
(go
(let [access-token (<! (auth/get-access-token))
[body response-type] (f.http/desired-response-type request)
body (ct/write writer body)
headers (assoc headers
"Content-Type" "application/transit+json"
"Authorization" (str "Bearer " access-token))]
(handler (merge request
{:body body
:headers headers
:method :post
:response-type response-type})))))))
([handler addl-transit-handlers]
(wrap-fulcro-request handler addl-transit-handlers nil))
([handler]
(wrap-fulcro-request handler nil nil))
([]
(wrap-fulcro-request identity nil nil)))

(defn fulcro-http-remote
"Create a remote that (by default) communicates with the given url
(which defaults to `/api`).
The request middleware is a `(fn [request] modified-request)`. The
`request` will have `:url`, `:body`, `:method`, and `:headers`. The
request middleware defaults to `wrap-fulcro-request` (which encodes
the request in transit+json). The result of this middleware chain on
the outgoing request becomes the real outgoing request. It is
allowed to modify the `url`.
If the the request middleware returns a corrupt request or throws an
exception then the remote code will immediately abort the request.
The return value of the middleware will be used to generate a
request to `:url`, with `:method` (e.g. :post), and the given
headers. The body will be sent as-is without further translation.
`response-middleware` is a function that returns a function `(fn
[response] mod-response)` and defaults to `wrap-fulcro-response`
which decodes the raw response and transforms it back to a response
that Fulcro can merge.
The response will be a map containing the `:outgoing-request` which
is the exact request sent on the network; `:body`, which is the raw
data of the response. Additionally, there will be one or more of the
following to indicate low-level details of the result:
`:status-code`, `:status-text`, `:error-code` (one of :none,
:exception, :http-error, :abort, or :timeout), and `:error-text`.
Middleware is allowed to morph any of this to suit its needs.
DEPRECATED: If the response middleware includes a `:transaction` key
in the response with EQL, then that EQL will be used in the
resulting Fulcro merge steps. This can seriously screw up built-in
behaviors. You are much better off ensuring that your query matches
the shape of the desired response in most cases.
The definition of `remote-error?` in the application will deterimine
if happy-path or error handling will be applied to the response. The
default setting in Fulcro will cause a result with a 200 status code
to cause whatever happy-path logic is configured for that specific
response's processing.
For example, see `m/default-result-action!` for mutations, and
`df/internal-load` for loads. The `:body` key will be considered the
response to use, and the optional `:transaction` key an override to
the EQL query used for any merges.
See the top-level application configuration and Developer's Guide
for more details."
[{:keys [url request-middleware response-middleware make-xhrio]
:or {url "/api"
response-middleware (f.http/wrap-fulcro-response)
request-middleware (wrap-fulcro-request)
make-xhrio f.http/make-xhrio}
:as options}]
(merge options
{:active-requests (atom {})
:transmit!
(fn transmit! [{:keys [active-requests]}
{::txn/keys [ast result-handler update-handler]
:as send-node}]
(go (let [edn (eql/ast->query ast)
ok-handler (fn [result]
(try
(result-handler result)
(catch :default e
(log/error e "Result handler for remote" url "failed with an exception."))))
progress-handler (fn [update-msg]
(let [msg {:status-code 200
:raw-progress (select-keys update-msg [:progress-phase :progress-event])
:overall-progress (f.http/progress% update-msg :overall)
:receive-progress (f.http/progress% update-msg :receiving)
:send-progress (f.http/progress% update-msg :sending)}]
(when update-handler
(try
(update-handler msg)
(catch :default e
(log/error e "Update handler for remote" url "failed with an exception."))))))
error-handler (fn [error-result]
(try
(result-handler (merge error-result {:status-code 500}))
(catch :default e
(log/error e "Error handler for remote" url "failed with an exception."))))]
(let-chan [real-request (try
(request-middleware {:headers {} :body edn :url url :method :post})
(catch :default e
(log/error e "Send aborted due to middleware failure ")
nil))]
(if real-request
(let [abort-id (or
(-> send-node ::txn/options ::txn/abort-id)
(-> send-node ::txn/options :abort-id))
xhrio (make-xhrio)
{:keys [body headers url method response-type]} real-request
http-verb (-> (or method :post) name str/upper-case)
extract-response #(f.http/extract-response body real-request xhrio)
extract-response-mw (f.http/response-extractor* response-middleware edn real-request xhrio)
gc-network-resources (f.http/cleanup-routine* abort-id active-requests xhrio)
progress-routine (f.http/progress-routine* extract-response progress-handler)
ok-routine (f.http/ok-routine* progress-routine extract-response-mw ok-handler error-handler)
error-routine (f.http/error-routine* extract-response-mw ok-routine progress-routine error-handler)
with-cleanup (fn [f] (fn [evt] (try (f evt) (finally (gc-network-resources)))))]
(when abort-id
(swap! active-requests update abort-id (fnil conj #{}) xhrio))
(when (and (f.http/legal-response-types response-type) (not= :default response-type))
(.setResponseType ^js xhrio (get f.http/response-types response-type)))
(when progress-handler
(f.http/xhrio-enable-progress-events xhrio)
(events/listen xhrio (.-DOWNLOAD_PROGRESS ^js EventType) #(progress-routine :receiving %))
(events/listen xhrio (.-UPLOAD_PROGRESS ^js EventType) #(progress-routine :sending %)))
(events/listen xhrio (.-SUCCESS ^js EventType) (with-cleanup ok-routine))
(events/listen xhrio (.-ABORT ^js EventType) (with-cleanup #(ok-handler {:status-text "Cancelled"
::txn/aborted? true})))
(events/listen xhrio (.-ERROR ^js EventType) (with-cleanup error-routine))
(f.http/xhrio-send xhrio url http-verb body headers))
(error-handler {:error :abort :error-text "Transmission was aborted because the request middleware returned nil or threw an exception"}))))))
:abort! (fn abort! [this id]
(if-let [xhrios (get @(:active-requests this) id)]
(doseq [xhrio xhrios]
(f.http/xhrio-abort xhrio))
(log/info "Unable to abort. No active request with abort id:" id)))}))
Loading

0 comments on commit c48056b

Please sign in to comment.