diff --git a/Makefile b/Makefile index dc33744..627ca55 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -.PHONY: lint test check clean test-config jar +.PHONY: lint test doc check clean test-config jar test-config: $(MAKE) -C test-config @@ -23,7 +23,11 @@ test: test-config clean: rm -rf ./*/classes ./*/target test-config/*pem ./*/*.jar ./*.zip -check: test lint outdated +check: test lint doc outdated + exit $$(git status --porcelain | tee /dev/fd/2 | wc -l) # fail when, after running the above, files in the repo changed + +doc: + make -C connector README.md outdated: clojure -T:antq outdated diff --git a/connector/.gitignore b/connector/.gitignore index 74ca806..947281b 100644 --- a/connector/.gitignore +++ b/connector/.gitignore @@ -5,4 +5,5 @@ /bdi-connector.jar /classes/ +/interceptors.md /rules.edn diff --git a/connector/Makefile b/connector/Makefile index 52bf5d0..59e2742 100644 --- a/connector/Makefile +++ b/connector/Makefile @@ -14,3 +14,10 @@ bdi-connector.jar: clean mkdir classes clojure -M -e "(compile 'org.bdinetwork.connector.main)" clojure -M:uberjar --main-class org.bdinetwork.connector.main --target $@ + +interceptors.md: deps.edn src/org/bdinetwork/connector/interceptors.clj src/org/bdinetwork/connector/interceptors/*.clj + clojure -X:print-interceptors > interceptors.md + +README.md: interceptors.md README.src.md + echo "" >$@ + sed "//r $<" README.src.md >>$@ diff --git a/connector/README.md b/connector/README.md index f878e15..9fed71c 100644 --- a/connector/README.md +++ b/connector/README.md @@ -1,3 +1,4 @@ + +`[passage.interceptors/logger & [props]]` - Example: +Short name: `logger` - - `GET http://localhost:8081/ HTTP/1.1 / 200 OK / 370ms` +Log incoming requests, response status and duration at `info` level. - Passing an extra expression will add MDC (Mapped Diagnostic Context) to the request log line with evaluated data. +Optional `props` will be evaluated in the "leave" or "error" +phase and logged as diagnostic context, `props` should be a shallow +map with string keys. - Example: +Example log messsage: - - match: `:match {:query-params {"pageNr" page-nr}}` - - interceptor: `[logger {"page-nr" page-nr, "uri" (:uri request)}]` - - request: `http://localhost:8081/test?pageNr=31415` - - log line: `GET http://localhost:8081/ HTTP/1.1 / 200 OK / 370ms page-nr=31415, uri="/test"` +``` +GET http://localhost:8081/ HTTP/1.1 / 200 OK / 370ms +``` -- `response` produces a literal response in the "entering" phase by evaluation the given expression. +Example with MDC: - Example: +```edn +[logger {"ua" (get-in request [:headers "user-agent"])}] +``` - ```edn - [response {:status 200, :body "hello world"}] - ``` +Example log message: -- `request` evaluates an update on the request in the "entering" phase, includes `request` and `ctx` in the evaluation environment. +``` +GET http://localhost:8080/ HTTP/1.1 / 200 OK / 123ms ua="curl/1.2.3" +``` - Example: +--- - ```edn - [request assoc-in [:headers "x-request"] "updated"] - ``` +`[passage.interceptors/proxy url]` -- `response` evaluates an update on the response in the "leaving" phase, includes `request`, `response` and `ctx` in the evaluation environment. +Short name: `proxy` - Example: +Send request to given URL in the "enter" phase and respond. + +When it fails to connect to the downstream server, respond with +`503 Service Unavailable`. + +Example: + +```edn +[proxy (str "https://example.com" (get request :uri))] +``` - ```edn - [response assoc-in [:headers "x-response"] "updated"] - ``` +Note: this interceptor should always be the last in the list of +interceptors. -- `proxy` produce a response by executing the (rewritten) request (including the recorded "x-forwarded" headers information in `:proxy-request-overrides`) in the "entering" phase. When the request fails to connect to the down stream server it responds with "503 Service Unavailable". +--- - Here are example rules for a minimal reverse proxy to [httpbin](https://httpbin.org): +`[passage.interceptors/request f & args]` - ```edn - [ - [proxy (str "https://httpbin.org (get request :uri))] - ``` +Short name: `request` -- `oauth2/bearer-token` require an OAuth 2.0 Bearer token with the given requirements and auth-params for a 401 Unauthorized response. The absence of a token or it not complying to the requirements causes a 401 Unauthorized response. At least the audience `:aud` and issuer `:iss` should be supplied to validate the token. The JWKs are derived from the issuer openid-configuration (issuer is expected to be a URL and the well-known suffix is appended), if not available `jwks-uri` should be supplied. The claims in the token will be available through var `oauth2/claims`. +Update the incoming request in the "enter" phase. - The following example expects a token from example.com and responds with "Hello subject" where "subject" is the "sub" of the token. +Example: - ```edn - [oauth2/bearer-token {:iss "http://example.com" - :aud "example"} - {:realm "example"}] - [response assoc :body (str "Hello " (get oauth2/claims :sub))] - [respond {:status 200}] - ``` +```edn +[request assoc-in [:headers "x-passage"] "passed"] +``` + +--- + +`[passage.interceptors/respond response]` + +Short name: `respond` + +Respond with given value in the "enter" phase. + +Example: + +```edn +[respond {:status 200 + :headers {"content-type" "text/plain"} + :body "hello, world"}] +``` - The claims for a valid access token will be place in `ctx` property `:oauth2/bearer-token-claims`. - - Both arguments to this intercepted are evaluated as an expression and includes `request` and `ctx` in the evaluation environment. - - ⚠ Consider removing the "authentication" header from the request after the `oauth2/bearer-token` interceptor using the `request` interceptor, see also [Strip tokens](#strip-tokens). ⚠ +Note: this interceptor should always be the last in the list of +interceptors. -- `bdi/authenticate` validate bearer token on incoming request, when none given responds with "401 Unauthorized", otherwise adds "X-Bdi-Client-Id" request header and vars for consumption downstream. Note: put this interceptor *before* `logger` when logging the client-id. +--- - Example: +`[passage.interceptors/response f & args]` - ```edn - [bdi/authenticate {:server-id "EU.EORI.CONNECTOR" - :private-key #private-key "certs/connector.key.pem" - :public-key #public-key "certs/connector.cert.pem" - :x5c #x5c "certs/connector.x5c.pem" - :association-server-id "EU.EORI.ASSOCIATION-REGISTER" - :association-server-url "https://association-register.com"}] - ``` +Short name: `response` -- `bdi/deauthenticate` ensure the "X-Bdi-Client-Id" request header is **not** already set on a request for public endpoints which do not need authentication. This prevents clients from fooling the backend into being authenticated. **Always use this on public routes when authentication is optional downstream.** +Update the outgoing response in the "leave" phase. -- `bdi/connect-token` provide a access token endpoint to provide access tokens for machine-to-machine (M2M) operations. Note: this interceptor does no matching, so it needs to be added to a separate rule with a match like: `{:uri "/connect/token", :request-method :post}`. +Example: + +```edn +[response update :headers dissoc "server"] +``` + +--- + +`[(passage.interceptors.oauth2/set-bearer-token) {:keys [token-endpoint client-id client-secret audience]}]` + +Short name: `oauth2/set-bearer-token` + +Set a bearer token on the `Authorization` header obtained from the +given `token-endpoint` and credentials. + +```edn +[(oauth2/set-bearer-token) {:token-endpoint "http://example.com/token" + :client-id "something" + :client-secret "something secret" + :audience "example"}] +``` + +--- + +`[(passage.interceptors.oauth2/validate-bearer-token) {:keys [aud iss], :as requirements} auth-params]` + +Short name: `oauth2/validate-bearer-token` + +Require and validate OAUTH2 bearer token according to `requirement`. +The absence of a token or it not complying with the requirements +causes a `401 Unauthorized` response including `auth-params`. + +At least the audience `:aud` and issuer `:iss` should be supplied to +validate the token. The JWKs are derived from the issuer +openid-configuration (issuer is expected to be a URL and the +well-known suffix is appended); if not available, `:jwks-uri` should +be supplied. + +The claims for a valid access token will be placed in the `ctx` +property `:oauth2/bearer-token-claims` and the `Authorization` +header is removed from the `:request` object. + +The following example expects a token from "example.com" and +responds with "Hello {subject}" where "{subject}" is the "sub" +of the token. + +```edn +[(oauth2/validate-bearer-token) {:iss "http://example.com" + :aud "example"} + {:realm "example"}] +[respond {:status 200 + :body (str "Hello " (get-in ctx [:oauth2/bearer-token-claims :sub]))}] +``` + +--- + +`[(org.bdinetwork.connector.interceptors/authenticate config)]` + +Short name: `bdi/authenticate` + +Enforce BDI authentication on incoming requests and add "x-bdi-client-id" request header. +Responds with 401 Unauthorized when request is not allowed. Example: + +``` +[bdi/authenticate {:server-id "EU.EORI.CONNECTOR" + :private-key #private-key "certs/connector.key.pem" + :public-key #public-key "certs/connector.cert.pem" + :x5c #x5c "certs/connector.x5c.pem" + :association-server-id "EU.EORI.ASSOCIATION-REGISTER" + :association-server-url "https://association-register.com"}] +``` + +--- + +`[(org.bdinetwork.connector.interceptors/connect-token config)]` + +Short name: `bdi/connect-token` + +Provide a token endpoint to provide access tokens for machine-to-machine (M2M) operations. + +Note: this interceptor does no matching. Example: + +``` +{:match {:uri "/connect/token"} + :interceptors + [[bdi/connect-token {:server-id "EU.EORI.CONNECTOR" + :private-key #private-key "certs/connector.key.pem" + :public-key #public-key "certs/connector.cert.pem" + :x5c #x5c "certs/connector.x5c.pem" + :association-server-id "EU.EORI.ASSOCIATION-REGISTER" + :association-server-url "https://association-register.com"}] + ..]} +``` + +--- + +`[org.bdinetwork.connector.interceptors/deauthenticate]` + +Short name: `bdi/deauthenticate` + +Ensure the "X-Bdi-Client-Id" request header is **not** already set on a request for public endpoints which do not need authentication. + +This prevents clients from fooling the backend into being +authenticated. **Always use this on public routes when +authentication is optional downstream.** + +--- + +`[org.bdinetwork.connector.interceptors/delegation]` + +Short name: `bdi/delegation` + +Retrieves and evaluates delegation evidence for request. +Responds with 403 Forbidden when the evidence is not found or does +not match the delegation mask. + +--- + +`[(org.bdinetwork.connector.interceptors/demo-audit-log {:keys [json-file], :as opts})]` + +Short name: `bdi/demo-audit-log` + +Provide access to the last `:n-of-lines` (defaults to 100) lines of `:json-file` (required) and render them in a HTML table. + +--- + +`[org.bdinetwork.connector.interceptors/noodlebar-delegation]` + +Short name: `bdi/noodlebar-delegation` + +Retrieves and evaluates delegation evidence for request. +Responds with 403 Forbidden when the evidence is not found or does +not match the delegation mask. + +--- + +`[(org.bdinetwork.connector.interceptors/set-bearer-token) {:keys [server-id base-url client-id private-key x5c association-id association-url path]}]` + +Short name: `bdi/set-bearer-token` + +Set a bearer token on the current request for the given `server-id` and `base-url`. + +Example: + +``` +[(bdi/set-bearer-token) {;; target server + :server-id server-id + :base-url server-url + + ;; credentials + :client-id server-id + :private-key private-key + :x5c x5c + + ;; association to check server adherence + :association-url association-server-url + :association-id association-server-id}] +``` - Example: +The `:path` can be added for a non-standard token endpoint location, +otherwise `/connect/token` is used. - ```edn - {:match {:uri "/connect/token"} - :interceptors - [[bdi/connect-token {:server-id "EU.EORI.CONNECTOR" - :private-key #private-key "certs/connector.key.pem" - :public-key #public-key "certs/connector.cert.pem" - :x5c #x5c "certs/connector.x5c.pem" - :association-server-id "EU.EORI.ASSOCIATION-REGISTER" - :association-server-url "https://association-register.com"}] - ``` ## Evaluation diff --git a/connector/README.src.md b/connector/README.src.md new file mode 100644 index 0000000..1f13972 --- /dev/null +++ b/connector/README.src.md @@ -0,0 +1,226 @@ + +# BDI Connector + +The BDI Connector gateway is a standalone service to authenticate and +authorize incoming HTTP requests. It can be configured to support +multiple authentication and authorization schemes compatible with the +[Basic Data Ifrastructure Trust Kit +architecture](https://bdi.gitbook.io/public/readme/trust-kit/authentication/authentication). + +## ⚠ DISCLAIMER ⚠ + +**The software is for development and testing purposes only!** It has +not been audited for security flaws and may not be suitable as a +starting point for production quality software. Use at your own risk. + +## Obtaining the BDI Connector + +The BDI Connector is distributed as a standalone Java jar file and as +a docker image. + +The jar file can be downloaded from [the BDI Stack releases page on +GitHub](https://github.com/Basic-Data-Infrastructure/bdi-stack/releases) +and can be run on a Java 21 runtime: + +```sh +java -jar bdi-connector-VERSION.jar +``` + +The docker image can be downloaded and run using docker or podman as +`bdinetwork.azurecr.io/connector`: + +```sh +docker run bdinetwork.azurecr.io/connector:VERSION +``` + +## Configuring the Gateway + +The gateway requires the following environment variables: + +- `RULES_FILE` + + The file of an EDN file describing the routing rules (see also section "Rules"). + +- `HOSTNAME` + + The hostname to listen on; defaults to `localhost`. + +- `PORT` + + The port number to listen on; defaults to `8081`. + +### Rules + +The rules file is parsed using [aero](https://github.com/juxt/aero) and is extended with the following tag literals: + +- `#rx` to produce regular expressions +- `#b64` to produce base64 encoded strings +- `#env!` same as `#env` but raises an error when the value is unset or blank +- `#private-key` read a private key from the given file name +- `#public-key` read a public key from the given file name +- `#x5c` read a certificate chain from the given file name + +Top-level configuration: + +- `:vars` used to globally extend the evaluation context for the "eval" interceptors +- `:rules` a list of rules to be matched and evaluated top to bottom when handling a request + +A rule contains: + +- `:match` a (partial) request object for matching an incoming request +- `:interceptors` a list of interceptors to apply to an incoming request and produce a response +- `:vars` (optional) rule-specific vars to extend the evaluation context of "eval" interceptors + +When no rule matches an incoming request, the gateway will respond with `404 Not Found`. + +#### Match + +Incoming requests are shaped as described in the [Ring Spec](https://github.com/ring-clojure/ring/blob/master/SPEC.md). A match expression describes the minimal properties a request must have to pass and allows capturing values from the request into vars. + +Maps, strings, vectors and keywords are to match exactly. Regular expressions will be applied to strings for matches and symbols will be allowed as placeholders to capture vars. + +The following will match all `GET` requests: + +```edn +{:request-method :get} +``` + +All requests to some path starting with `/foo/bar` and capture the referer URL in the `?referer` var. Note that header names are case-insensitive, so a lowercase name is used to match. + +```edn +{:uri #rx "/foo/bar.*" + :headers {"referer" ?referer}} +``` + +#### Interceptors + +An interceptor operates on either the "entering" or "leaving" / "error" phase of an interaction, or both. In the "entering" phase no response has been formulated yet. When an interceptor does produce a response or an exception is raise in the "entering" phase the already visited interceptors are executed in the reverse order; this is the "leaving" or in case of an exception "error" phase. + +This gateway comes with the following base interceptors: + + + +## Evaluation + +The arguments to interceptors will be evaluated before execution and can thus rely on vars or values put on `ctx` by earlier steps. The evaluation support the following functions: + +- `assoc` +- `assoc-in` +- `get` +- `get-in` +- `merge` +- `select-keys` +- `update` +- `update-in` +- `str` +- `str/replace` +- `str/lower-case` +- `str/upper-case` +- `=` +- `not` + +and special forms: + +- `if` +- `or` +- `and` + +and have access to the following vars: + +- `ctx` +- `request` +- `response` (when already available) +- and all `vars` defined globally, on a rule +- and captured by `match`. + +The `response` is only available when it's not an *async* object like the result of the `reverse-proxy/proxy-request` interceptor. + +### Error handling + +The gateway will respond with "502 Bad Gateway" when an interceptor throws an exception. When this happens the interceptor "error" phase handlers will be executed allowing for customized responses. + +#### Example + +The following example is protected by a basic authentication username / password and passes authenticated requests on to a backend which is also protected by basic authentication but with a different username / password. + + +```edn +{:rules [{:match {:headers {"authorization" + #join ["Basic " #b64 #join [#env! "USER" ":" #env! "PASS"]]}} + :interceptors + [[logger] + [request update :headers assoc "authorization" + #join ["Basic " #b64 #join [#env! "BACKEND_USER" ":" #env! "BACKEND_PASS"]]] + [response update :headers assoc "x-bdi-connector" "passed"] + [proxy (str "http://backend:port/" (get request :uri))]]} + + {:match {} + :interceptors [[logger] + [respond {:status 401 + :headers {"content-type" "text/plain" + "www-authenticate" "Basic realm=\"secret\""} + :body "not allowed"}]]}]} +``` + +### WebSockets? + +Not supported (yet). + +## Forward Proxy + +This connector can be used as a HTTP Forward Proxy but does not support HTTPS. See [Connector HTTP(S) Forward Proxy](../connector-forward-proxy.md) for more information. + +## Security considerations + +### End-user header overrides + +The connector sits between the consumer and the provider, any HTTP request header from the consumer is passed on to the provider thus sensitive headers which, for example, are used to allow access MUST be filtered out using the `request` or `bdi/deauthenticate` (for `X-Bdi-Client-Id`) interceptor. For example: + +```edn +[request update :headers dissoc "x-user-id"] +``` + +⚠ Headers case insensitive and always lower case in a request object, so when removing a header using `dissoc` use the lower case value! ⚠ + +### Strip tokens + +Authentication an authorization tokens handled by the connector SHOULD be stripped before passing the request to a backend. For example, when using `oauth2/bearer-token` interceptor, remove the "authorization" header immediately after. + +```edn +[oauth2/bearer-token {:iss "http://example.com" + :aud "example"} + {:realm "example"}] +[request update :headers dissoc "authorization"] +``` + +⚠ Headers case insensitive and always lower case in a request object, so when removing a header using `dissoc` use the lower case value! ⚠ + +## Development + +### Building the connector from source + +The connector can be build from source as part of the BDI-Stack, by running + +```sh +make bdi-connector.jar +``` + +in the root of this repository. See also [the "Developing" section in the top-level README file](../README.md#developing). + +### Running the test suite + +To run the test suite, run: + +```sh +make test +``` + +On systems derived from BSD (like MacOS), the tests may timeout waiting to bind to `127.0.0.2`. If that's the case, set up a loopback device on that address using something like (tested on OpenBSD and MacOS): + +```sh +ifconfig lo0 alias 127.0.0.2 up +``` diff --git a/connector/deps.edn b/connector/deps.edn index dfb617c..de9fc4b 100644 --- a/connector/deps.edn +++ b/connector/deps.edn @@ -13,11 +13,11 @@ aleph/aleph {:mvn/version "0.9.3"} nl.jomco/clj-http-status-codes {:mvn/version "0.2"} nl.jomco/passage {:git/url "https://codeberg.org/jomco/passage.git" - :git/sha "7faf6a8ffae8784c162711b51eb3847ed4cfb803"} - org.clojure/data.json {:mvn/version "2.5.1"} - ring/ring-core {:mvn/version "1.15.3"} - ring/ring-json {:mvn/version "0.5.1"} - hiccup/hiccup {:mvn/version "2.0.0"} + :git/sha "0c2f080d46be86c94a9a005725663bfba13661e1"} + org.clojure/data.json {:mvn/version "2.5.1"} + ring/ring-core {:mvn/version "1.15.3"} + ring/ring-json {:mvn/version "0.5.1"} + hiccup/hiccup {:mvn/version "2.0.0"} nl.jomco/with-resources {:mvn/version "0.1.2"} nl.jomco/envopts {:mvn/version "0.0.7"} @@ -29,4 +29,9 @@ :uberjar {:replace-deps {uberdeps/uberdeps {:mvn/version "RELEASE"}} :replace-paths [] :main-opts ["-m" "uberdeps.uberjar" "--aliases" "package"]} - :run {:main-opts ["-m" "org.bdinetwork.connector.main"]}}} + :run {:main-opts ["-m" "org.bdinetwork.connector.main"]} + + :print-interceptors {:replace-paths ["src"] ;; prevent log messages + :exec-fn passage.interceptors/print-docs + :exec-args {:extra-namespaces [org.bdinetwork.connector.interceptors] + :ns-alias {bdi org.bdinetwork.connector.interceptors}}}}} diff --git a/connector/src/org/bdinetwork/connector/interceptors.clj b/connector/src/org/bdinetwork/connector/interceptors.clj index 85d6ee2..e8c25d6 100644 --- a/connector/src/org/bdinetwork/connector/interceptors.clj +++ b/connector/src/org/bdinetwork/connector/interceptors.clj @@ -5,16 +5,20 @@ (ns org.bdinetwork.connector.interceptors (:require [clojure.data.json :as json] [clojure.tools.logging :as log] + [manifold.deferred :as d] [nl.jomco.http-status-codes :as http-status] [org.bdinetwork.authentication.access-token :as access-token] [org.bdinetwork.authentication.client-assertion :as client-assertion] [org.bdinetwork.authentication.in-memory-association :refer [in-memory-association read-source]] [org.bdinetwork.authentication.remote-association :refer [remote-association]] [org.bdinetwork.connector.interceptors.audit-log :refer [audit-log-response]] + [org.bdinetwork.ishare.client :as ishare-client] + [org.bdinetwork.ishare.client.request :as ishare-request] [org.bdinetwork.ishare.client.validate-delegation :as validate-delegation] [passage.response :as response] [ring.middleware.json :as ring-json] - [ring.middleware.params :as ring-params])) + [ring.middleware.params :as ring-params]) + (:import java.time.Instant)) (defn extract-client-id [request config] (let [auth (get-in request [:headers "authorization"])] @@ -27,7 +31,16 @@ (defn ^{:interceptor true} authenticate "Enforce BDI authentication on incoming requests and add \"x-bdi-client-id\" request header. - Responds with 401 Unauthorized when request is not allowed." + Responds with 401 Unauthorized when request is not allowed. Example: + + ``` + [bdi/authenticate {:server-id \"EU.EORI.CONNECTOR\" + :private-key #private-key \"certs/connector.key.pem\" + :public-key #public-key \"certs/connector.cert.pem\" + :x5c #x5c \"certs/connector.x5c.pem\" + :association-server-id \"EU.EORI.ASSOCIATION-REGISTER\" + :association-server-url \"https://association-register.com\"}] + ```" [config] {:enter (fn authenticate-enter [{:keys [request] :as ctx}] @@ -40,7 +53,11 @@ :headers {"www-authenticate" "Bearer scope=\"BDI\""}})))}) (def ^{:interceptor true} deauthenticate - "Remove \"x-bdi-client-id\" request header for avoid clients from fooling backend into being authenticated." + "Ensure the \"X-Bdi-Client-Id\" request header is **not** already set on a request for public endpoints which do not need authentication. + + This prevents clients from fooling the backend into being + authenticated. **Always use this on public routes when + authentication is optional downstream.**" {:enter (fn bdi-deauthenticate-enter [ctx] (update-in ctx [:request :headers] dissoc "x-bdi-client-id"))}) @@ -66,9 +83,21 @@ (ring-json/json-response {}))) (defn ^{:interceptor true} connect-token - "Provide a access token (M2M) endpoint to acquire an authentication token. - Note: this interceptor does not match on an `uri`, use a `:match` in - the rules for that." + "Provide a token endpoint to provide access tokens for machine-to-machine (M2M) operations. + + Note: this interceptor does no matching. Example: + + ``` + {:match {:uri \"/connect/token\"} + :interceptors + [[bdi/connect-token {:server-id \"EU.EORI.CONNECTOR\" + :private-key #private-key \"certs/connector.key.pem\" + :public-key #public-key \"certs/connector.cert.pem\" + :x5c #x5c \"certs/connector.x5c.pem\" + :association-server-id \"EU.EORI.ASSOCIATION-REGISTER\" + :association-server-url \"https://association-register.com\"}] + ..]} + ```" [config] (let [jti-cache-atom (client-assertion/mk-jti-cache-atom) config (assoc config @@ -87,7 +116,7 @@ {:enter (fn delegation-enter [ctx {:keys [server-id x5c private-key association-server-id association-server-url dataspace-id] :as _config} mask] - (assert [server-id x5c private-key association-server-id association-server-url dataspace-id]) + {:pre [server-id x5c private-key association-server-id association-server-url dataspace-id]} (let [base-request {:ishare/satellite-base-url association-server-url :ishare/satellite-id association-server-id :ishare/x5c x5c @@ -109,6 +138,99 @@ +(def expires-in-fraction + "Fraction of expires/max-age seconds to consider." + 90/100) + +(defn- now-in-epoch-seconds + [] + (.getEpochSecond (Instant/now))) + +(defn- deferred-cached + "Deferred cache of `c` for key `k` and `:payload` of `f`. + Returned `:exp` from `f` is epoch seconds of expiration. + + Note: this cache does not evict entries, it only validates it + expiration date, so the assumption the amount of `k`s is limited." + [c k f] + {:pre [c]} + (d/let-flow + [{:keys [payload]} + (-> (swap! c update k + (fn [slot] + (if slot + (d/let-flow [{:keys [exp]} slot] + (if (< (now-in-epoch-seconds) exp) + slot + (f))) + (f)))) + (get k))] + payload)) + +(defn- get-bearer-token-cache-slot + "Return a non blocking future of `{:payload \"token\", :exp 1234}` for arguments." + [{:keys [server-id base-url client-id private-key x5c association-id association-url path]}] + (future + (let [res (-> {:ishare/server-id server-id + :ishare/base-url base-url + + ;; credentials + :ishare/client-id client-id + :ishare/private-key private-key + :ishare/x5c x5c + + ;; for adherence test of server + :ishare/satellite-id association-id + :ishare/satellite-url association-url} + + (ishare-request/access-token-request path) + (ishare-client/exec))] + {:payload (:ishare/result res) + :exp (+ (now-in-epoch-seconds) + (* expires-in-fraction + (-> res :body (get "expires_in"))))}))) + +(defn ^{:interceptor true + :expr-arglist '[{:keys [server-id base-url client-id private-key x5c association-id association-url path]}]} + set-bearer-token + "Set a bearer token on the current request for the given `server-id` and `base-url`. + + Example: + + ``` + [(bdi/set-bearer-token) {;; target server + :server-id server-id + :base-url server-url + + ;; credentials + :client-id server-id + :private-key private-key + :x5c x5c + + ;; association to check server adherence + :association-url association-server-url + :association-id association-server-id}] + ``` + + The `:path` can be added for a non-standard token endpoint location, + otherwise `/connect/token` is used." + [] + (let [cache (atom {})] + {:enter + (fn set-bearer-token-enter + [ctx + {:keys [server-id base-url + client-id private-key x5c + association-id association-url] + :as config}] + {:pre [client-id private-key x5c base-url server-id association-id association-url]} + (d/let-flow + [token (deferred-cached cache [server-id base-url client-id association-id] + #(get-bearer-token-cache-slot config))] + (assoc-in ctx [:request :headers "authorization"] + (str "Bearer " token))))})) + + (defn ^{:interceptor true} demo-audit-log "Provide access to the last `:n-of-lines` (defaults to 100) lines of `:json-file` (required) and render them in a HTML table." [{:keys [json-file] :as opts}] diff --git a/connector/test/org/bdinetwork/connector/interceptors_test.clj b/connector/test/org/bdinetwork/connector/interceptors_test.clj index b96a6ba..8e19111 100644 --- a/connector/test/org/bdinetwork/connector/interceptors_test.clj +++ b/connector/test/org/bdinetwork/connector/interceptors_test.clj @@ -16,22 +16,30 @@ [org.bdinetwork.ishare.jwt :as ishare-jwt] [org.bdinetwork.service-commons.config :as config] [org.bdinetwork.test-helper :refer [jwks-keys mk-token openid-token-uri openid-uri proxy-url start-backend start-openid start-proxy]] + [org.bdinetwork.test.system-helpers + :refer [association-system + association-server-id + association-server-url]] [passage :as gateway] [passage.interceptors] [ring.adapter.jetty :refer [run-jetty]] [ring.util.codec :as ring-codec]) (:import (java.io StringBufferInputStream) (java.time Instant))) + (def server-id "EU.EORI.CONNECTOR") -(def config - {:server-id server-id +(def client-config + {:client-id server-id :private-key (keys/private-key (io/resource "test-config/connector.key.pem")) :public-key (keys/public-key (io/resource "test-config/connector.cert.pem")) - :x5c (config/split-x5c (io/resource "test-config/connector.x5c.pem")) + :x5c (config/split-x5c (io/resource "test-config/connector.x5c.pem"))}) - :in-memory-association-data-source - (io/resource "test-config/association-register-config.yml")}) +(def config + (merge client-config + {:server-id server-id + :in-memory-association-data-source + (io/resource "test-config/association-register-config.yml")})) (defn mk-access-token [client-id] (access-token/mk-access-token (assoc config :client-id client-id))) @@ -113,6 +121,19 @@ (let [{:strs [token_type]} (json/read-str (:body response))] (is (= "Bearer" token_type)))))) + + +(deftest set-bearer-token + (with-resources [_association-system (association-system)] + (let [{:keys [enter]} (interceptors/set-bearer-token) + config (assoc client-config + :server-id association-server-id + :base-url association-server-url + :association-id association-server-id + :association-url association-server-url) + ctx @(enter {} config)] + (is (re-matches #"Bearer eyJ.+" ;; JWTs always start with 'eyJ' because it's base64 JSON '{"' + (get-in ctx [:request :headers "authorization"])))))) diff --git a/connector/test/org/bdinetwork/connector/system_test.clj b/connector/test/org/bdinetwork/connector/system_test.clj index 2e60324..ebb347d 100644 --- a/connector/test/org/bdinetwork/connector/system_test.clj +++ b/connector/test/org/bdinetwork/connector/system_test.clj @@ -6,7 +6,9 @@ ;;; SPDX-License-Identifier: AGPL-3.0-or-later (ns org.bdinetwork.connector.system-test - (:require [clojure.test :refer [deftest is testing]] + (:require [babashka.http-client :as http] + [clojure.data.json :as json] + [clojure.test :refer [deftest is testing]] [nl.jomco.http-status-codes :as http-status] [nl.jomco.resources :refer [with-resources]] [org.bdinetwork.ishare.client :as client] @@ -19,6 +21,7 @@ backend-connector-id backend-connector-request backend-connector-system + backend-connector-url client-config client-id data-owner-config @@ -94,7 +97,23 @@ :method :get :path "/api/bdi/authenticated" :ishare/bearer-token "NONSENSE")))) - "status unauthorized"))) + "status unauthorized")) + + (testing "setting bearer token; accessing association register with automatically set token" + (let [{:keys [status body] + {:strs [content-type]} :headers} + (http/get (str backend-connector-url "/api/bdi/party?party-id=" client-id) + {:throw false})] + (is (= http-status/ok status)) + (is (re-matches #"application/json\b.*" content-type)) + (is (contains? (json/read-str body) + "party_token"))) + + (testing "should not work without token" + (let [{:keys [status]} + (http/get (str backend-connector-url "/api/bdi/party?party-id=" client-id "&without-token=1") + {:throw false})] + (is (= http-status/unauthorized status)))))) (testing "authorization" (testing "accessing authorzed backend" diff --git a/test-config/backend-connector.edn b/test-config/backend-connector.edn index fda6832..cdeb228 100644 --- a/test-config/backend-connector.edn +++ b/test-config/backend-connector.edn @@ -61,6 +61,24 @@ [bdi/deauthenticate] [proxy "http://localhost:9994"]]} + {:match {:uri "/api/bdi/party" + :params {"party-id" party-id}} + :interceptors [[logger {"uri" (get request :uri) + "server" "backend-connector"}] + [(bdi/set-bearer-token) {:server-id association-server-id + :base-url association-server-url + :client-id server-id + :private-key private-key + :x5c x5c + :association-url association-server-url + :association-id association-server-id}] + [request update :headers dissoc + ;; remove just acquired bearer token when "without-token" param passed + (if (get-in request [:params "without-token"]) + "authorization" + "dummy")] + [proxy (str association-server-url "/parties/" party-id)]]} + ;;;; noodlebar/oauth authentication and authorization ;; allow valid openid access token