-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
239 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,3 +8,4 @@ target/ | |
.lsp/ | ||
.envrc | ||
opentelemetry-javaagent.jar | ||
.nrepl-port |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
115 changes: 115 additions & 0 deletions
115
src/nl/surf/eduhub/validator/service/authentication.clj
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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))))) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
97 changes: 97 additions & 0 deletions
97
test/nl/surf/eduhub/validator/service/authentication_test.clj
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")))) |