Skip to content

Commit

Permalink
Added authentication plus tests
Browse files Browse the repository at this point in the history
  • Loading branch information
mdemare committed Sep 10, 2024
1 parent 3f9ee2a commit ef3dd5e
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 10 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ target/
.lsp/
.envrc
opentelemetry-javaagent.jar
.nrepl-port
9 changes: 5 additions & 4 deletions deps.edn
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
{:paths ["src" "resources" "classes"]
:deps {cheshire {:mvn/version "5.13.0"}
ch.qos.logback/logback-classic {:mvn/version "1.5.8"}
ch.qos.logback.contrib/logback-jackson {:mvn/version "0.1.5"}
{:paths ["src" "test" "resources" "classes"]
:deps {ch.qos.logback.contrib/logback-jackson {:mvn/version "0.1.5"}
ch.qos.logback.contrib/logback-json-classic {:mvn/version "0.1.5"}
ch.qos.logback/logback-classic {:mvn/version "1.5.8"}
cheshire/cheshire {:mvn/version "5.13.0"}
com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.17.2"}
compojure/compojure {:mvn/version "1.7.1"}
nl.jomco/clj-http-status-codes {:mvn/version "0.1"}
nl.jomco/envopts {:mvn/version "0.0.4"}
org.babashka/http-client {:mvn/version "0.4.19"}
org.babashka/json {:mvn/version "0.1.6"}
org.clojure/clojure {:mvn/version "1.12.0"}
org.clojure/core.memoize {:mvn/version "1.1.266"}
org.clojure/tools.logging {:mvn/version "1.3.0"}
ring/ring-codec {:mvn/version "1.2.0"}
ring/ring-core {:mvn/version "1.12.2"}
Expand Down
115 changes: 115 additions & 0 deletions src/nl/surf/eduhub/validator/service/authentication.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
;; This file is part of eduhub-rio-mapper
;;
;; Copyright (C) 2022 SURFnet B.V.
;;
;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Affero General Public License
;; as published by the Free Software Foundation, either version 3 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; Affero General Public License for more details.
;;
;; You should have received a copy of the GNU Affero General Public
;; License along with this program. If not, see
;; <https://www.gnu.org/licenses/>.

(ns nl.surf.eduhub.validator.service.authentication
"Authenticate incoming HTTP API requests using SURFconext.
This uses the OAuth2 Client Credentials flow for authentication. From
the perspective of the RIO Mapper HTTP API (a Resource Server in
OAuth2 / OpenID Connect terminology), this means that:
1. Calls to the API should contain an Authorization header with a
Bearer token.
2. The token is verified using the Token Introspection endpoint,
provided by SURFconext.
The Token Introspection endpoint is described in RFC 7662.
The SURFconext service has extensive documentation. For our use
case you can start here:
https://wiki.surfnet.nl/display/surfconextdev/Documentation+for+Service+Providers
The flow we use is documented at https://wiki.surfnet.nl/pages/viewpage.action?pageId=23794471 "
(:require [babashka.http-client :as http]
[cheshire.core :as json]
[clojure.core.memoize :as memo]
[clojure.tools.logging :as log]
[nl.jomco.http-status-codes :as http-status]
[ring.util.response :as response]))

(defn bearer-token
[{{:strs [authorization]} :headers}]
(some->> authorization
(re-matches #"Bearer ([^\s]+)")
second))

;; Take a authentication uri, basic auth credentials and a token extracted from the bearer token
;; and make a call to the authentication endpoint.
;; Returns the client id if authentication is successful, otherwise nil.
(defn authenticate-token [uri token auth]
{:pre [(string? uri)
(string? token)
(map? auth)]}
(try
(let [opts {:basic-auth auth :form-params {:token token} :throw false}
{:keys [status] :as response} (http/post uri opts)]
(when (= http-status/ok status)
;; See RFC 7662, section 2.2
(let [json (json/parse-string (:body response) true)
active (:active json)]
(when-not (boolean? active)
(throw (ex-info "Invalid response for token introspection, active is not boolean."
{:body json})))
(when active
(:client_id json)))))
(catch Exception ex
(log/error ex "Error in token-authenticator")
nil)))

(defn make-token-authenticator
"Make a token authenticator that uses the OIDC `introspection-endpoint`.
Returns a authenticator that tests the token using the given
`instrospection-endpoint` and returns the token's client id if the
token is valid.
Returns nil unless the authentication service returns a response with a 200 code."
[introspection-endpoint auth]
{:pre [introspection-endpoint auth]}
(fn [token]
(authenticate-token introspection-endpoint
token
{:basic-auth auth})))

(defn handle-request-with-token [request request-handler client-id]
(if (nil? client-id)
(response/status http-status/forbidden)
;; set client-id on request and response (for tracing)
(-> request
(assoc :client-id client-id)
request-handler
(assoc :client-id client-id))))

(defn wrap-authentication
"Authenticate calls to ring handler `f` using `token-authenticator`.
The token authenticator will be called with the Bearer token from
the incoming http request. If the authenticator returns a client-id,
the client-id gets added to the request as `:client-id` and the
request is handled by `f`. If the authenticator returns `nil` or
if the http status of the authenticator call is not successful, the
request is forbidden.
If no bearer token is provided, the request is executed without a client-id."
; auth looks like {:user client-id :pass client-secret}
[f introspection-endpoint auth]
(let [authenticator (memo/ttl (make-token-authenticator introspection-endpoint auth) :ttl/threshold 60000)] ; 1 minute
(fn [request]
(if-let [token (bearer-token request)]
(handle-request-with-token request f (authenticator token))
(f request)))))
27 changes: 21 additions & 6 deletions src/nl/surf/eduhub/validator/service/main.clj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
[environ.core :refer [env]]
[nl.jomco.envopts :as envopts]
[nl.jomco.http-status-codes :as http-status]
[nl.surf.eduhub.validator.service.authentication :as auth]
[ring.adapter.jetty :refer [run-jetty]]
[ring.middleware.defaults :refer [wrap-defaults api-defaults]]
[ring.middleware.json :refer [wrap-json-response]]))
Expand Down Expand Up @@ -44,17 +45,31 @@
(def opt-specs
{:gateway-url ["URL of gateway" :str :in [:gateway-url]]
:gateway-basic-auth-user ["Basic auth username of gateway" :str :in [:gateway-basic-auth :user]]
:gateway-basic-auth-pass ["Basic auth password of gateway" :str :in [:gateway-basic-auth :pass]]})
:gateway-basic-auth-pass ["Basic auth password of gateway" :str :in [:gateway-basic-auth :pass]]
:introspection-client-id ["Basic auth username of introspection" :str :in [:introspection-basic-auth :user]]
:introspection-secret ["Basic auth password of introspection" :str :in [:introspection-basic-auth :pass]]
:introspection-endpoint ["Introspection endpoint url" :str :in [:introspection-endpoint-url]]})

(defn start-server [routes]
(let [server (run-jetty routes {:port 3002 :join? false})
handler ^Runnable (fn [] (.stop server))]
;; Add a shutdown hook to stop Jetty on JVM exit (Ctrl+C)
(.addShutdownHook (Runtime/getRuntime)
(Thread. handler))
server))

(defn -main [& _]
(let [[config errs] (envopts/opts env opt-specs)]
(let [[config errs] (envopts/opts env opt-specs)
introspection-endpoint (:introspection-endpoint-url config)
introspection-auth (:introspection-basic-auth config)]
(when errs
(.println *err* "Error in environment configuration")
(.println *err* (envopts/errs-description errs))
(.println *err* "Available environment vars:")
(.println *err* (envopts/specs-description opt-specs))
(System/exit 1))
(run-jetty (-> app-routes
(wrap-validator config)
(wrap-defaults api-defaults)
wrap-json-response) {:port 3002})))
(start-server (-> app-routes
(wrap-validator config)
(auth/wrap-authentication introspection-endpoint introspection-auth)
(wrap-defaults api-defaults)
wrap-json-response))))
97 changes: 97 additions & 0 deletions test/nl/surf/eduhub/validator/service/authentication_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
;; This file is part of eduhub-rio-mapper
;;
;; Copyright (C) 2022 SURFnet B.V.
;;
;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Affero General Public License
;; as published by the Free Software Foundation, either version 3 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; Affero General Public License for more details.
;;
;; You should have received a copy of the GNU Affero General Public
;; License along with this program. If not, see
;; <https://www.gnu.org/licenses/>.

(ns nl.surf.eduhub.validator.service.authentication-test
(:require [babashka.http-client :as http]
[cheshire.core :as json]
[clojure.test :refer [deftest is]]
[nl.jomco.http-status-codes :as http-status]
[nl.surf.eduhub.validator.service.authentication :as authentication]))

(deftest test-bearer-token
(is (nil?
(authentication/bearer-token {:headers {"authorization" "Bearerfoobar"}})))
(is (= "foobar"
(authentication/bearer-token {:headers {"authorization" "Bearer foobar"}}))))

(def valid-token
(str "eyJraWQiOiJrZXlfMjAyMl8xMF8wNF8wMF8wMF8wMF8wMDEiLCJ0eXAiOiJK"
"V1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJwbGF5Z3JvdW5kX2NsaWVudCI"
"sInN1YiI6InBsYXlncm91bmRfY2xpZW50IiwibmJmIjoxNjY0ODkyNTg0LCJ"
"pc3MiOiJodHRwczpcL1wvY29ubmVjdC50ZXN0LnN1cmZjb25leHQubmwiLCJ"
"leHAiOjE2NjU3NTY1ODQsImlhdCI6MTY2NDg5MjU4NCwianRpIjoiNTlkNGY"
"yZDQtZmRhOC00MTBjLWE2MzItY2QzMzllMTliNTQ2In0.nkQqZK02SamkNI2"
"ICDrE1LxN6kBBDOwQd5zU9BsPxNIfOwP1qnCwNQELo5xX0R2cJJJqCgmq8nw"
"BjZ4xNba4lTS8dii4Fmy-8u7fN427mx-_G-GoCGQSKQD6OdVKjDsRMJX4rHN"
"DSg5HhtDz5or-2Xp_H0Vi0mWMOBgQGjfbjLjJJZ1T0rlaZbq-_ZAatb2dFcr"
"WliqbFrous_fSPo4jrbPVHYunF-wZZoLZFlOaCyJM24A_3Mrv4JPw78WRnyu"
"ZG0H7aS2v_KLe5Xh2lUkSa0lkO_xP2uhQQ_69bnmF0RQiKe9vVDi7mhi0aGE"
"do2f-iJ8JQj4EwPzZkSvdJt569w"))


(def count-calls (atom 0))

;; Mock out the introspection endpoint. Pretend token is active if
;; it's equal to `valid-token`, invalid otherwise.
(defn mock-introspection
[{:keys [form-params]}]
(swap! count-calls inc)
(if (= valid-token (:token form-params))
{:status http-status/ok
:body (json/encode {:active true
:client_id "institution_client_id"})}
{:status http-status/ok
:body {:active false}}))

(deftest token-validator
;; This binds the *dynamic* http client in clj-http.client
(reset! count-calls 0)
(with-redefs [http/request mock-introspection]
(let [introspection-endpoint "https://example.com"
basic-auth {:user "foo" :pass "bar"}
handler (-> (fn [req]
(let [body {:client (:client-id req)}]
{:status http-status/ok
:body body}))
(authentication/wrap-authentication introspection-endpoint basic-auth))]
(is (= {:status http-status/ok
:body {:client "institution_client_id"}
:client-id "institution_client_id"}
(handler {:headers {"authorization" (str "Bearer " valid-token)}}))
"Ok when valid token provided")

(is (= {:status 200, :body {:client nil}}
(handler {}))
"Authorized without client when no token provided")

(is (= http-status/forbidden
(:status (handler {:headers {"authorization" (str "Bearer invalid-token")}})))
"Forbidden with invalid token")

(is (= 2 @count-calls)
"Correct number of calls to introspection-endpoint")

(reset! count-calls 0)
(is (= {:status http-status/ok
:body {:client "institution_client_id"}
:client-id "institution_client_id"}
(handler {:headers {"authorization" (str "Bearer " valid-token)}}))
"CACHED: Ok when valid token provided")

(is (= 0 @count-calls)
"No more calls to introspection-endpoint"))))

0 comments on commit ef3dd5e

Please sign in to comment.