Skip to content

Commit c48056b

Browse files
committed
Implement API authentication and authorization.
1 parent b75318c commit c48056b

File tree

13 files changed

+358
-40
lines changed

13 files changed

+358
-40
lines changed

deps.edn

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
ring/ring-core {:mvn/version "1.8.0"}
88
ring/ring-defaults {:mvn/version "0.3.2"}
99
bk/ring-gzip {:mvn/version "0.3.0"}
10+
com.auth0/java-jwt {:mvn/version "3.10.2"}
11+
com.auth0/jwks-rsa {:mvn/version "0.11.0"}
1012
integrant {:mvn/version "0.8.0"}
1113
seancorfield/next.jdbc {:mvn/version "1.0.409"}
1214
honeysql {:mvn/version "0.9.10"}
@@ -15,21 +17,21 @@
1517
hikari-cp {:mvn/version "2.11.0"}
1618
aero {:mvn/version "1.1.6"}
1719
http-kit {:mvn/version "2.4.0-alpha6"}}
18-
:aliases {:dev {:extra-paths ["dev"]
19-
:jvm-opts ["-Dtrace"]
20-
:extra-deps {org.clojure/tools.namespace {:mvn/version "1.0.0"}
21-
org.clojure/clojurescript {:mvn/version "1.10.597"}
22-
com.fulcrologic/semantic-ui-wrapper {:mvn/version "1.0.0"}
23-
org.clojure/core.async {:mvn/version "1.1.587"}
24-
com.cognitect/transit-cljs {:mvn/version "0.8.256"}
25-
com.wsscode/async {:mvn/version "1.0.3"}
26-
clj-commons/pushy {:mvn/version "0.3.10"}
27-
thheller/shadow-cljs {:mvn/version "2.8.94"}
28-
binaryage/devtools {:mvn/version "1.0.0"}
29-
integrant/repl {:mvn/version "0.3.1"}}}
30-
:test {:extra-paths ["test"]
31-
:extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git"
32-
:sha "209b64504cb3bd3b99ecfec7937b358a879f55c1"}}}
33-
:run-tests {:main-opts ["-m" "cognitect.test-runner"]}
34-
:outdated {:extra-deps {olical/depot {:mvn/version "1.8.4"}}
35-
:main-opts ["-m" "depot.outdated.main"]}}}
20+
:aliases {:dev {:extra-paths ["dev"]
21+
:jvm-opts ["-Dtrace"]
22+
:extra-deps {org.clojure/tools.namespace {:mvn/version "1.0.0"}
23+
org.clojure/clojurescript {:mvn/version "1.10.597"}
24+
com.fulcrologic/semantic-ui-wrapper {:mvn/version "1.0.0"}
25+
org.clojure/core.async {:mvn/version "1.1.587"}
26+
com.cognitect/transit-cljs {:mvn/version "0.8.256"}
27+
com.wsscode/async {:mvn/version "1.0.3"}
28+
clj-commons/pushy {:mvn/version "0.3.10"}
29+
thheller/shadow-cljs {:mvn/version "2.8.94"}
30+
binaryage/devtools {:mvn/version "1.0.0"}
31+
integrant/repl {:mvn/version "0.3.1"}}}
32+
:test {:extra-paths ["test"]
33+
:extra-deps {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git"
34+
:sha "209b64504cb3bd3b99ecfec7937b358a879f55c1"}}}
35+
:run-tests {:main-opts ["-m" "cognitect.test-runner"]}
36+
:outdated {:extra-deps {olical/depot {:mvn/version "1.8.4"}}
37+
:main-opts ["-m" "depot.outdated.main"]}}}

dev/user.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
rocks.mygiftlist.parser
55
rocks.mygiftlist.db
66
rocks.mygiftlist.config
7+
rocks.mygiftlist.authentication
78
[integrant.core :as ig]
89
[integrant.repl :refer [clear go halt prep init reset reset-all]]
910
[integrant.repl.state :refer [system]]

resources/config.edn

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@
88
:sslmode #or [#env POSTGRES_SSLMODE "disable"]}
99
:port #long #profile {:dev 3000
1010
:test 3001
11-
:prod #env PORT}}
11+
:prod #env PORT}
12+
:auth {:jwk-endpoint #or [#env JWK_ENDPOINT "https://mygiftlist-blog.auth0.com/.well-known/jwks.json"]
13+
:issuer #or [#env JWT_ISSUER "https://mygiftlist-blog.auth0.com/"]
14+
:audience #or [#env JWT_AUDIENCE "https://blog.mygiftlist.rocks"]}}

resources/system.edn

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
{:rocks.mygiftlist.config/config
22
{:rocks.mygiftlist.config/profile :prod}
33

4+
:rocks.mygiftlist.authentication/wrap-jwt
5+
{:rocks.mygiftlist.config/config #ig/ref :rocks.mygiftlist.config/config}
6+
47
:rocks.mygiftlist.db/pool
58
{:rocks.mygiftlist.config/config #ig/ref :rocks.mygiftlist.config/config}
69

@@ -9,4 +12,5 @@
912

1013
:rocks.mygiftlist.server/server
1114
{:rocks.mygiftlist.parser/parser #ig/ref :rocks.mygiftlist.parser/parser
15+
:rocks.mygiftlist.authentication/wrap-jwt #ig/ref :rocks.mygiftlist.authentication/wrap-jwt
1216
:rocks.mygiftlist.config/config #ig/ref :rocks.mygiftlist.config/config}}

src/rocks/mygiftlist/application.cljs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
(ns rocks.mygiftlist.application
22
(:require [com.fulcrologic.fulcro.application :as app]
33
[com.fulcrologic.fulcro.rendering.keyframe-render2 :as keyframe-render2]
4-
[com.fulcrologic.fulcro.networking.http-remote :as http-remote]
4+
[com.fulcrologic.fulcro.networking.http-remote :as f.http-remote]
5+
[rocks.mygiftlist.http-remote :as http-remote]
56
[rocks.mygiftlist.transit :as transit]))
67

78
(defonce SPA
@@ -12,5 +13,5 @@
1213
(http-remote/wrap-fulcro-request
1314
identity transit/write-handlers)
1415
:response-middleware
15-
(http-remote/wrap-fulcro-response
16+
(f.http-remote/wrap-fulcro-response
1617
identity transit/read-handlers)})}}))
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
(ns rocks.mygiftlist.authentication
2+
(:require [rocks.mygiftlist.config :as config]
3+
[integrant.core :as ig])
4+
(:import [java.net URL]
5+
[java.time Instant]
6+
[com.auth0.jwk GuavaCachedJwkProvider UrlJwkProvider]
7+
[com.auth0.jwt.interfaces RSAKeyProvider]
8+
[com.auth0.jwt JWT]
9+
[com.auth0.jwt.algorithms Algorithm]
10+
[com.auth0.jwt.exceptions JWTVerificationException]))
11+
12+
(defn create-key-provider [url]
13+
(let [provider (-> url
14+
(URL.)
15+
(UrlJwkProvider.)
16+
(GuavaCachedJwkProvider.))]
17+
(reify RSAKeyProvider
18+
(getPublicKeyById [_ key-id]
19+
(-> provider
20+
(.get key-id)
21+
(.getPublicKey)))
22+
(getPrivateKey [_] nil)
23+
(getPrivateKeyId [_] nil))))
24+
25+
(defn verify-token
26+
"Given a key-provider created by `create-key-provider`, an issuer,
27+
an audience, and a jwt, decodes the jwt and returns it if the jwt is
28+
valid. Returns nil if the jwt is invalid."
29+
[key-provider {:keys [issuer audience]} token]
30+
(let [algorithm (Algorithm/RSA256 key-provider)
31+
verifier (-> algorithm
32+
(JWT/require)
33+
(.withIssuer (into-array String [issuer]))
34+
(.withAudience (into-array String [audience]))
35+
(.build))]
36+
(try
37+
(let [decoded-jwt (.verify verifier token)]
38+
{:iss (.getIssuer decoded-jwt)
39+
:sub (.getSubject decoded-jwt)
40+
:aud (vec (.getAudience decoded-jwt))
41+
:iat (.toInstant (.getIssuedAt decoded-jwt))
42+
:exp (.toInstant (.getExpiresAt decoded-jwt))
43+
:azp (.asString (.getClaim decoded-jwt "azp"))
44+
:scope (.asString (.getClaim decoded-jwt "scope"))})
45+
(catch JWTVerificationException e
46+
nil))))
47+
48+
(defn- get-token [req]
49+
(when-let [header (get-in req [:headers "authorization"])]
50+
(second (re-find #"^Bearer (.+)" header))))
51+
52+
(defn wrap-jwt
53+
"Middleware that verifies and adds claim data to a request based on
54+
a bearer token in the header.
55+
56+
If a bearer token is found in the authorization header, attempts to
57+
verify it. If verification succeeds, adds the token's claims to the
58+
request under the `::claims` key. If verification fails, leaves the
59+
request unchanged."
60+
[handler key-provider expected-claims]
61+
(fn [req]
62+
(let [token (get-token req)
63+
claims (when token
64+
(verify-token key-provider expected-claims token))]
65+
(handler (cond-> req
66+
claims (assoc ::claims claims))))))
67+
68+
(defmethod ig/init-key ::wrap-jwt
69+
[_ {::config/keys [config]}]
70+
(fn [handler]
71+
(wrap-jwt handler
72+
(create-key-provider
73+
(config/jwk-endpoint config))
74+
{:issuer (config/jwt-issuer config)
75+
:audience (config/jwt-audience config)})))

src/rocks/mygiftlist/config.clj

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,12 @@
1313

1414
(defn port [config]
1515
(:port config))
16+
17+
(defn jwk-endpoint [config]
18+
(get-in config [:auth :jwk-endpoint]))
19+
20+
(defn jwt-issuer [config]
21+
(get-in config [:auth :issuer]))
22+
23+
(defn jwt-audience [config]
24+
(get-in config [:auth :audience]))
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
(ns rocks.mygiftlist.http-remote
2+
(:refer-clojure :exclude [send])
3+
(:require
4+
[clojure.string :as str]
5+
[cognitect.transit :as ct]
6+
[com.fulcrologic.fulcro.algorithms.transit :as t]
7+
[com.fulcrologic.fulcro.algorithms.tx-processing :as txn]
8+
[com.fulcrologic.fulcro.networking.http-remote :as f.http]
9+
[clojure.core.async :refer [go <!]]
10+
[com.wsscode.async.async-cljs :refer [let-chan]]
11+
[edn-query-language.core :as eql]
12+
[goog.events :as events]
13+
[taoensso.timbre :as log]
14+
[rocks.mygiftlist.authentication :as auth])
15+
(:import [goog.net XhrIo EventType ErrorCode]))
16+
17+
(defn wrap-fulcro-request
18+
([handler addl-transit-handlers transit-transformation]
19+
(let [writer (t/writer (cond-> {}
20+
addl-transit-handlers
21+
(assoc :handlers addl-transit-handlers)
22+
23+
transit-transformation
24+
(assoc :transform transit-transformation)))]
25+
(fn [{:keys [headers body] :as request}]
26+
(go
27+
(let [access-token (<! (auth/get-access-token))
28+
[body response-type] (f.http/desired-response-type request)
29+
body (ct/write writer body)
30+
headers (assoc headers
31+
"Content-Type" "application/transit+json"
32+
"Authorization" (str "Bearer " access-token))]
33+
(handler (merge request
34+
{:body body
35+
:headers headers
36+
:method :post
37+
:response-type response-type})))))))
38+
([handler addl-transit-handlers]
39+
(wrap-fulcro-request handler addl-transit-handlers nil))
40+
([handler]
41+
(wrap-fulcro-request handler nil nil))
42+
([]
43+
(wrap-fulcro-request identity nil nil)))
44+
45+
(defn fulcro-http-remote
46+
"Create a remote that (by default) communicates with the given url
47+
(which defaults to `/api`).
48+
49+
The request middleware is a `(fn [request] modified-request)`. The
50+
`request` will have `:url`, `:body`, `:method`, and `:headers`. The
51+
request middleware defaults to `wrap-fulcro-request` (which encodes
52+
the request in transit+json). The result of this middleware chain on
53+
the outgoing request becomes the real outgoing request. It is
54+
allowed to modify the `url`.
55+
56+
If the the request middleware returns a corrupt request or throws an
57+
exception then the remote code will immediately abort the request.
58+
The return value of the middleware will be used to generate a
59+
request to `:url`, with `:method` (e.g. :post), and the given
60+
headers. The body will be sent as-is without further translation.
61+
`response-middleware` is a function that returns a function `(fn
62+
[response] mod-response)` and defaults to `wrap-fulcro-response`
63+
which decodes the raw response and transforms it back to a response
64+
that Fulcro can merge.
65+
66+
The response will be a map containing the `:outgoing-request` which
67+
is the exact request sent on the network; `:body`, which is the raw
68+
data of the response. Additionally, there will be one or more of the
69+
following to indicate low-level details of the result:
70+
`:status-code`, `:status-text`, `:error-code` (one of :none,
71+
:exception, :http-error, :abort, or :timeout), and `:error-text`.
72+
73+
Middleware is allowed to morph any of this to suit its needs.
74+
75+
DEPRECATED: If the response middleware includes a `:transaction` key
76+
in the response with EQL, then that EQL will be used in the
77+
resulting Fulcro merge steps. This can seriously screw up built-in
78+
behaviors. You are much better off ensuring that your query matches
79+
the shape of the desired response in most cases.
80+
81+
The definition of `remote-error?` in the application will deterimine
82+
if happy-path or error handling will be applied to the response. The
83+
default setting in Fulcro will cause a result with a 200 status code
84+
to cause whatever happy-path logic is configured for that specific
85+
response's processing.
86+
87+
For example, see `m/default-result-action!` for mutations, and
88+
`df/internal-load` for loads. The `:body` key will be considered the
89+
response to use, and the optional `:transaction` key an override to
90+
the EQL query used for any merges.
91+
92+
See the top-level application configuration and Developer's Guide
93+
for more details."
94+
[{:keys [url request-middleware response-middleware make-xhrio]
95+
:or {url "/api"
96+
response-middleware (f.http/wrap-fulcro-response)
97+
request-middleware (wrap-fulcro-request)
98+
make-xhrio f.http/make-xhrio}
99+
:as options}]
100+
(merge options
101+
{:active-requests (atom {})
102+
:transmit!
103+
(fn transmit! [{:keys [active-requests]}
104+
{::txn/keys [ast result-handler update-handler]
105+
:as send-node}]
106+
(go (let [edn (eql/ast->query ast)
107+
ok-handler (fn [result]
108+
(try
109+
(result-handler result)
110+
(catch :default e
111+
(log/error e "Result handler for remote" url "failed with an exception."))))
112+
progress-handler (fn [update-msg]
113+
(let [msg {:status-code 200
114+
:raw-progress (select-keys update-msg [:progress-phase :progress-event])
115+
:overall-progress (f.http/progress% update-msg :overall)
116+
:receive-progress (f.http/progress% update-msg :receiving)
117+
:send-progress (f.http/progress% update-msg :sending)}]
118+
(when update-handler
119+
(try
120+
(update-handler msg)
121+
(catch :default e
122+
(log/error e "Update handler for remote" url "failed with an exception."))))))
123+
error-handler (fn [error-result]
124+
(try
125+
(result-handler (merge error-result {:status-code 500}))
126+
(catch :default e
127+
(log/error e "Error handler for remote" url "failed with an exception."))))]
128+
(let-chan [real-request (try
129+
(request-middleware {:headers {} :body edn :url url :method :post})
130+
(catch :default e
131+
(log/error e "Send aborted due to middleware failure ")
132+
nil))]
133+
(if real-request
134+
(let [abort-id (or
135+
(-> send-node ::txn/options ::txn/abort-id)
136+
(-> send-node ::txn/options :abort-id))
137+
xhrio (make-xhrio)
138+
{:keys [body headers url method response-type]} real-request
139+
http-verb (-> (or method :post) name str/upper-case)
140+
extract-response #(f.http/extract-response body real-request xhrio)
141+
extract-response-mw (f.http/response-extractor* response-middleware edn real-request xhrio)
142+
gc-network-resources (f.http/cleanup-routine* abort-id active-requests xhrio)
143+
progress-routine (f.http/progress-routine* extract-response progress-handler)
144+
ok-routine (f.http/ok-routine* progress-routine extract-response-mw ok-handler error-handler)
145+
error-routine (f.http/error-routine* extract-response-mw ok-routine progress-routine error-handler)
146+
with-cleanup (fn [f] (fn [evt] (try (f evt) (finally (gc-network-resources)))))]
147+
(when abort-id
148+
(swap! active-requests update abort-id (fnil conj #{}) xhrio))
149+
(when (and (f.http/legal-response-types response-type) (not= :default response-type))
150+
(.setResponseType ^js xhrio (get f.http/response-types response-type)))
151+
(when progress-handler
152+
(f.http/xhrio-enable-progress-events xhrio)
153+
(events/listen xhrio (.-DOWNLOAD_PROGRESS ^js EventType) #(progress-routine :receiving %))
154+
(events/listen xhrio (.-UPLOAD_PROGRESS ^js EventType) #(progress-routine :sending %)))
155+
(events/listen xhrio (.-SUCCESS ^js EventType) (with-cleanup ok-routine))
156+
(events/listen xhrio (.-ABORT ^js EventType) (with-cleanup #(ok-handler {:status-text "Cancelled"
157+
::txn/aborted? true})))
158+
(events/listen xhrio (.-ERROR ^js EventType) (with-cleanup error-routine))
159+
(f.http/xhrio-send xhrio url http-verb body headers))
160+
(error-handler {:error :abort :error-text "Transmission was aborted because the request middleware returned nil or threw an exception"}))))))
161+
:abort! (fn abort! [this id]
162+
(if-let [xhrios (get @(:active-requests this) id)]
163+
(doseq [xhrio xhrios]
164+
(f.http/xhrio-abort xhrio))
165+
(log/info "Unable to abort. No active request with abort id:" id)))}))

0 commit comments

Comments
 (0)