Skip to content

Commit

Permalink
✨ Login callback handler for OIDC authorization code flow
Browse files Browse the repository at this point in the history
Why:
- We want to migrate from SPA to SSR. The authentication is currently
  done using Auth0's JavaScript libraries, but in the future it'll need
  to be done server-side.
- Uses the auth0-java-mvc-common library to avoid security risks of
  implementing the OIDC spec incorrectly.
  • Loading branch information
luontola committed Feb 24, 2024
1 parent b872d62 commit 349fb9a
Show file tree
Hide file tree
Showing 10 changed files with 142 additions and 31 deletions.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions .idea/libraries/Leiningen__org_objenesis_objenesis_3_3.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions project.clj
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
[etaoin "1.0.40"]
[lambdaisland/kaocha "1.87.1366"]
[org.clojure/test.check "1.1.1"]
[org.mockito/mockito-core "5.10.0"]
[prismatic/schema-generators "0.1.5"]
[ring/ring-devel "1.11.0"]
[ring/ring-mock "0.4.0"]]
Expand Down
44 changes: 31 additions & 13 deletions src/territory_bro/infra/auth0.clj
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,25 @@
;; The license text is at http://www.apache.org/licenses/LICENSE-2.0

(ns territory-bro.infra.auth0
(:require [clojure.pprint :as pp]
[clojure.string :as str]
(:require [clojure.string :as str]
[clojure.tools.logging :as log]
[meta-merge.core :refer [meta-merge]]
[mount.core :as mount]
[ring.util.http-response :as http-response]
[ring.util.response :as response]
[territory-bro.api :as api]
[territory-bro.infra.authentication :as auth]
[territory-bro.infra.config :as config]
[territory-bro.infra.json :as json]
[territory-bro.infra.util :as util]
[territory-bro.infra.util :refer [getx]])
(:import (com.auth0 AuthenticationController)
(:import (com.auth0 AuthenticationController IdentityVerificationException)
(com.auth0.jwk JwkProviderBuilder)
(com.auth0.jwt JWT)
(java.net URL)
(javax.servlet.http Cookie HttpServletRequest HttpServletResponse HttpSession)))

(mount/defstate ^AuthenticationController auth-controller
(mount/defstate ^:dynamic ^AuthenticationController auth-controller
:start
(let [domain (getx config/env :auth0-domain)
client-id (getx config/env :auth0-client-id)
Expand Down Expand Up @@ -89,21 +95,33 @@
callback-url (str public-url "/login-callback")
[servlet-request servlet-response *ring-response] (ring->servlet ring-request)
authorize-url (-> (.buildAuthorizeUrl auth-controller servlet-request servlet-response callback-url)
(.withScope "openid email profile")
(.build))]
(meta-merge @*ring-response
(response/redirect authorize-url :see-other))))

(defn login-callback-handler [ring-request]
(prn '----------)
(pp/pprint ring-request)
;; TODO
(response/response "TODO"))
(try
(let [[servlet-request servlet-response *ring-response] (ring->servlet ring-request)
tokens (.handle auth-controller servlet-request servlet-response)
id-token (-> (.getIdToken tokens)
(JWT/decode)
(.getPayload)
(util/decode-base64url)
(json/read-value))
user-id (api/save-user-from-jwt! id-token)
session (-> (:session @*ring-response)
(assoc ::tokens tokens)
(merge (auth/user-session id-token user-id)))]
(log/info "Logged in using OIDC. ID token was" id-token)
;; TODO: redirect to original page
(-> (response/redirect "/" :see-other)
(assoc :session session)))
(catch IdentityVerificationException e
(log/warn e "Login failed")
;; TODO: html error page
(http-response/forbidden "Login failed"))))

(defn logout-handler [ring-request]
;; TODO
(response/response "TODO"))

;; TODO: adapter for Ring request -> HttpServletRequest
;; TODO: adapter for HttpServletResponse -> Ring response
;; TODO: adapter for Ring session -> HttpSession -> Ring session
;; TODO: adapter for com.auth0.AuthenticationController
14 changes: 4 additions & 10 deletions src/territory_bro/infra/jwt.clj
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
;; Copyright © 2015-2023 Esko Luontola
;; Copyright © 2015-2024 Esko Luontola
;; This software is released under the Apache License 2.0.
;; The license text is at http://www.apache.org/licenses/LICENSE-2.0

(ns territory-bro.infra.jwt
(:require [mount.core :as mount]
[territory-bro.infra.config :refer [env]]
[territory-bro.infra.json :as json]
[territory-bro.infra.util :as util]
[territory-bro.infra.util :refer [getx]])
(:import (com.auth0.jwk JwkProvider JwkProviderBuilder)
(com.auth0.jwt JWT JWTVerifier$BaseVerification)
(com.auth0.jwt.algorithms Algorithm)
(java.nio.charset StandardCharsets)
(java.time Clock Instant ZoneOffset)
(java.util Base64)))
(java.time Clock Instant ZoneOffset)))

(mount/defstate ^:dynamic ^JwkProvider jwk-provider
:start (-> (JwkProviderBuilder. ^String (getx env :auth0-domain))
Expand All @@ -22,11 +21,6 @@
(let [key-id (.getKeyId (JWT/decode jwt))]
(.getPublicKey (.get jwk-provider key-id))))

(defn- decode-base64url [^String base64-str]
(-> (Base64/getUrlDecoder)
(.decode base64-str)
(String. StandardCharsets/UTF_8)))

(defn- ^"[Ljava.lang.String;" strings [& ss]
(into-array String ss))

Expand All @@ -41,7 +35,7 @@
(.build clock))]
(-> (.verify verifier jwt)
(.getPayload)
(decode-base64url)
(util/decode-base64url)
(json/read-value))))

(defn expired?
Expand Down
11 changes: 9 additions & 2 deletions src/territory_bro/infra/util.clj
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
;; Copyright © 2015-2019 Esko Luontola
;; Copyright © 2015-2024 Esko Luontola
;; This software is released under the Apache License 2.0.
;; The license text is at http://www.apache.org/licenses/LICENSE-2.0

(ns territory-bro.infra.util
(:import (java.sql SQLException)))
(:import (java.nio.charset StandardCharsets)
(java.sql SQLException)
(java.util Base64)))

(defn fix-sqlexception-chain [^Throwable e]
(when (instance? SQLException e)
Expand All @@ -24,3 +26,8 @@
value)))

(def conj-set (fnil conj #{}))

(defn decode-base64url [^String base64-str]
(-> (Base64/getUrlDecoder)
(.decode base64-str)
(String. StandardCharsets/UTF_8)))
4 changes: 4 additions & 0 deletions territory-bro.iml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@
<orderEntry type="library" name="Leiningen: mount:0.1.17" level="project" />
<orderEntry type="library" name="Leiningen: mvxcvi/arrangement:2.1.0" level="project" />
<orderEntry type="library" name="Leiningen: mvxcvi/puget:1.1.2" level="project" />
<orderEntry type="library" name="Leiningen: net.bytebuddy/byte-buddy-agent:1.14.11" level="project" />
<orderEntry type="library" name="Leiningen: net.bytebuddy/byte-buddy:1.14.11" level="project" />
<orderEntry type="library" name="Leiningen: net.incongru.watchservice/barbary-watchservice:1.0" level="project" />
<orderEntry type="library" name="Leiningen: net.java.dev.jna/jna:5.12.1" level="project" />
<orderEntry type="library" name="Leiningen: net.jodah/expiringmap:0.5.8" level="project" />
Expand Down Expand Up @@ -181,8 +183,10 @@
<orderEntry type="library" name="Leiningen: org.jetbrains.kotlin/kotlin-stdlib-jdk8:1.9.22" level="project" />
<orderEntry type="library" name="Leiningen: org.jetbrains.kotlin/kotlin-stdlib:1.9.22" level="project" />
<orderEntry type="library" name="Leiningen: org.jetbrains/annotations:13.0" level="project" />
<orderEntry type="library" name="Leiningen: org.mockito/mockito-core:5.10.0" level="project" />
<orderEntry type="library" name="Leiningen: org.msgpack/msgpack:0.6.12" level="project" />
<orderEntry type="library" name="Leiningen: org.nrepl/incomplete:0.1.0" level="project" />
<orderEntry type="library" name="Leiningen: org.objenesis/objenesis:3.3" level="project" />
<orderEntry type="library" name="Leiningen: org.postgresql/postgresql:42.7.1" level="project" />
<orderEntry type="library" name="Leiningen: org.ring-clojure/ring-jakarta-servlet:1.11.0" level="project" />
<orderEntry type="library" name="Leiningen: org.ring-clojure/ring-websocket-protocols:1.11.0" level="project" />
Expand Down
63 changes: 57 additions & 6 deletions test/territory_bro/infra/auth0_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@
[ring.util.codec :as codec]
[ring.util.http-predicates :as http-predicates]
[ring.util.response :as response]
[territory-bro.api :as api]
[territory-bro.infra.auth0 :as auth0]
[territory-bro.infra.config :as config])
(:import (java.net URL)
(javax.servlet.http Cookie HttpServletRequest HttpServletResponse)))
[territory-bro.infra.authentication :as auth]
[territory-bro.infra.config :as config]
[territory-bro.infra.jwt-test :as jwt-test])
(:import (com.auth0 AuthenticationController IdentityVerificationException Tokens)
(java.net URL)
(java.util UUID)
(javax.servlet.http Cookie HttpServletRequest HttpServletResponse)
(org.mockito Mockito)))

(defn config-fixture [f]
(mount/start #'config/env
Expand All @@ -37,7 +43,7 @@
(is (= "luontola.eu.auth0.com" (.getHost location)))
(is (= {:redirect_uri "http://localhost:8081/login-callback"
:client_id "8tVkdfnw8ynZ6rXNndD6eZ6ErsHdIgPi"
:scope "openid"
:scope "openid email profile"
:response_type "code"}
(dissoc query-params :state))))

Expand All @@ -46,10 +52,55 @@
(is (not (str/blank? state)))
(is (= [(str "com.auth0.state=" state "; HttpOnly; Max-Age=600; SameSite=Lax")]
(response/get-header response "Set-Cookie")))
(is (= {:territory-bro.infra.auth0/servlet {"com.auth0.state" state
"com.auth0.nonce" nil}} ; nonce is not needed in authorization code flow, see https://community.auth0.com/t/is-nonce-requried-for-the-authoziation-code-flow/111419
(is (= {::auth0/servlet {"com.auth0.state" state
"com.auth0.nonce" nil}} ; nonce is not needed in authorization code flow, see https://community.auth0.com/t/is-nonce-requried-for-the-authoziation-code-flow/111419
(:session response)))))))


(deftest login-callback-handler-test
(binding [auth0/auth-controller (Mockito/mock ^Class AuthenticationController)
api/save-user-from-jwt! (fn [jwt]
(is (= {:name "Esko Luontola"}
(select-keys jwt [:name])))
(UUID. 0 1))]
(let [request {:request-method :get
:scheme :http
:server-name "localhost"
:server-port 8081
:uri "/login-callback"
:params {:code "mjuZmU8Tw3WO9U4n6No3PJ1g3kTJzYoEYX2nfK8_0U8wY"
:state "XJ3KBCcGcXmLnH09gb2AVsQp9bpjiVxLXvo5N4SKEqw"}
:cookies {"com.auth0.state" {:value "XJ3KBCcGcXmLnH09gb2AVsQp9bpjiVxLXvo5N4SKEqw"}}
:session {::auth0/servlet {"com.auth0.state" "XJ3KBCcGcXmLnH09gb2AVsQp9bpjiVxLXvo5N4SKEqw",
"com.auth0.nonce" nil}}}
tokens (Tokens. "the-access-token" jwt-test/token nil nil nil)]

(testing "successful login"
(-> (Mockito/when (.handle auth0/auth-controller (Mockito/any) (Mockito/any)))
(.thenReturn tokens))
(let [response (auth0/login-callback-handler request)]
(is (= {:status 303,
:headers {"Location" "/"},
:body "",
:session {::auth0/servlet {"com.auth0.state" "XJ3KBCcGcXmLnH09gb2AVsQp9bpjiVxLXvo5N4SKEqw",
"com.auth0.nonce" nil}
::auth0/tokens tokens
::auth/user {:user/id (UUID. 0 1)
:sub "google-oauth2|102883237794451111459"
:name "Esko Luontola"
:nickname "esko.luontola"
:picture "https://lh6.googleusercontent.com/-AmDv-VVhQBU/AAAAAAAAAAI/AAAAAAAAAeI/bHP8lVNY1aA/photo.jpg"}}}
response))))

(testing "failed login"
(-> (Mockito/when (.handle auth0/auth-controller (Mockito/any) (Mockito/any)))
(.thenThrow ^Class IdentityVerificationException))
(let [response (auth0/login-callback-handler request)]
(is (= {:status 403
:headers {}
:body "Login failed"}
response)))))))

(deftest ring->servlet-test
(testing "request URL"
;; Servlet spec: The returned URL contains a protocol, server name, port number,
Expand Down

0 comments on commit 349fb9a

Please sign in to comment.