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.
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.
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 and can be run on a Java 21 runtime:
java -jar bdi-connector-VERSION.jarThe docker image can be downloaded and run using docker or podman as
bdinetwork.azurecr.io/connector:
docker run bdinetwork.azurecr.io/connector:VERSIONThe gateway requires the following environment variables:
-
RULES_FILEThe file of an EDN file describing the routing rules (see also section "Rules").
-
HOSTNAMEThe hostname to listen on; defaults to
localhost. -
PORTThe port number to listen on; defaults to
8081.
The rules file is parsed using aero and is extended with the following tag literals:
#rxto produce regular expressions#b64to produce base64 encoded strings#env!same as#envbut raises an error when the value is unset or blank#private-keyread a private key from the given file name#public-keyread a public key from the given file name#x5cread a certificate chain from the given file name
Top-level configuration:
:varsused to globally extend the evaluation context for the "eval" interceptors:rulesa list of rules to be matched and evaluated top to bottom when handling a request
A rule contains:
:matcha (partial) request object for matching an incoming request:interceptorsa 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.
Incoming requests are shaped as described in the Ring Spec. 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:
{: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.
{:uri #rx "/foo/bar.*"
:headers {"referer" ?referer}}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:
[passage.interceptors/logger & [props]]
Short name: logger
Log incoming requests, response status and duration at info level.
Optional props will be evaluated in the "leave"
phase and logged as diagnostic context, props should be a shallow
map with string keys.
Example log messsage:
GET http://localhost:8081/ HTTP/1.1 / 200 OK / 370ms
Example with MDC:
[logger {"ua" (get-in request [:headers "user-agent"])}]Example log message:
GET http://localhost:8080/ HTTP/1.1 / 200 OK / 123ms ua="curl/1.2.3"
[passage.interceptors/proxy path-prefix? url]
Short name: proxy
Send request to service at url and return the reponse.
When it fails to connect to the downstream server, respond with
503 Service Unavailable.
Strip optional path-prefix from the path in incoming request.
Example:
[proxy "/strip/prefix" "https://example.com/api"]Will pass incoming request at "/strip/prefix/resource?foo=bar" to "https://example.com/api/resource?foo=bar".
Note: this interceptor should always be the last in the list of interceptors.
[passage.interceptors/request f & args]
Short name: request
Update the incoming request in the "enter" phase.
Example:
[request assoc-in [:headers "x-passage"] "passed"][passage.interceptors/respond response]
Short name: respond
Respond with given value in the "enter" phase.
Example:
[respond {:status 200
:headers {"content-type" "text/plain"}
:body "hello, world"}]Note: this interceptor should always be the last in the list of interceptors.
[passage.interceptors/response f & args]
Short name: response
Update the outgoing response in the "leave" phase.
Example:
[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.
[(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.
[(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}]
The :path can be added for a non-standard token endpoint location,
otherwise /connect/token is used.
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:
assocassoc-ingetget-inmergeselect-keysupdateupdate-instrstr/replacestr/lower-casestr/upper-case=not
and special forms:
iforand
and have access to the following vars:
ctxrequestresponse(when already available)- and all
varsdefined 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.
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.
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.
{: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 "http://backend:port/"]]}
{:match {}
:interceptors [[logger]
[respond {:status 401
:headers {"content-type" "text/plain"
"www-authenticate" "Basic realm=\"secret\""}
:body "not allowed"}]]}]}Not supported (yet).
This connector can be used as a HTTP Forward Proxy but does not support HTTPS. See Connector HTTP(S) Forward Proxy for more information.
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:
[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! ⚠
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.
[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! ⚠
The connector can be build from source as part of the BDI-Stack, by running
make bdi-connector.jarin the root of this repository. See also the "Developing" section in the top-level README file.
To run the test suite, run:
make testOn 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):
ifconfig lo0 alias 127.0.0.2 up