- Clojure 97.5%
- Makefile 2.5%
|
All checks were successful
/ check (push) Successful in 1m19s
Override netty dependency from aleph. |
||
|---|---|---|
| .clj-kondo | ||
| .forgejo/workflows | ||
| dev | ||
| doc | ||
| LICENSES | ||
| resources | ||
| src | ||
| test | ||
| test-resources | ||
| .gitignore | ||
| .manifest.scm | ||
| deps.edn | ||
| Makefile | ||
| README.md | ||
| README.src.md | ||
| tests.edn | ||
Passage — Flexible HTTP Processing
Passage is a customizable HTTP pipeline. It acts as an HTTP server that processes requests using a configurable stack of interceptors that can perform forward-proxying, authentication and authorization, echo and drip responses for debugging, validation, and more.
Configuring
Passage requires the following environment variables:
-
RULES_FILEAn 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
8080.
Rules
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
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:ns-aliasa map for aliases to namespaces for interceptor lookups
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, Passage will respond with 404 Not Found.
Match
Incoming requests are shaped as described in the Ring Spec. In addition to this the query string is parsed and its keys / values are added as :query-params and :params. Form parameters from the request body are not parsed.
A match expression describes the minimal properties a request must have to pass and allows capturing values from the request into vars.
Matching algorithm
Strings, numbers and keywords match if they are equal to the corresponding property in the request.
Symbols are allowed as placeholders to capture vars. If a symbol is present multiple times, it must match a value equal to the captured var.
Regular expressions match if the corresponding property in the request is a string that matches the regular expression.
For a map to match, it must match a map in which the given keys must be present and the corresponding values match according to these rules recursively. Keys not present in the match expression are ignored -- additional keys may be present in the request.
For vectors to match, it must match a vector, with every element in the match expression matching the corresponding value. Additional items may be present in the request.
Examples
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}}
Interceptors
An interceptor operates on the "enter" or "leave" / "error" phase of an interaction. In the "enter" phase, no response has been produced. When an interceptor does produce a response, or an exception is raised in the "enter" phase, the already visited interceptors are executed in the reverse order; this is the "leaving" or, in the case of an exception, "error" phase.
Interceptors operate on the interaction context which will be the first argument to a phase handler. A context has the following shape in the "enter" phase:
{:trace-id #uuid "01234567-890a-bcdef0123-4567890abcde"
:vars {'some-var 123}
:request {:request-method 'get
:uri "/test" ..}}
When an interceptor adds a :response it will reverse into the "leave" phase finalizing the response.
{:trace-id #uuid "01234567-890a-bcdef0123-4567890abcde"
:vars {'some-var 123}
:request {:request-method 'get
:uri "/test" ..}
:response {:status 200
:headers {"content-type" "text/plain"}
:body "hello"}}
Back on top of the stack of interceptors, the response is sent.
Passage comes with the following 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" or "error"
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 url]
Short name: proxy
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:
[proxy (str "https://example.com" (get request :uri))]
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]))}]
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 supports 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 proxy interceptor.
Error handling
Passage 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.
{: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-passage" "passed"]
[proxy #env! BACKEND_URL]]}
{:match {}
:interceptors [[logger]
[respond {:status 401
:headers {"content-type" "text/plain"
"www-authenticate" "Basic realm=\"secret\""}
:body "not allowed"}]]}]}
Custom interceptors
Here's an example interceptor:
(ns org.example.my-interceptors)
(def ^{:interceptor true}
greeter
{:enter (fn [ctx msg]
(prn "hello!")
(assoc ctx :greeter/msg msg))
:leave (fn [ctx _]
(prn "bye..")
ctx)
:error (fn [ctx _]
(prn "auch..")
ctx)})
Use them in your rules file:
{:rules [{:match {:query-params {"name" name}}
:vars {greeting "hello"}
:interceptors [[org.example.my-interceptors/greeter name]
[respond {:status 200
:headers {"content-type" "text/plain"}
:body (str greeting " " (get ctx :greeter/msg))}]]}]}
Using ns-alias:
{:ns-alias {my org.example.my-interceptors}
:rules [{:match {:query-params {"name" name}}
:vars {greeting "hello"}
:interceptors [[my/greeter name]
[respond {:status 200
:headers {"content-type" "text/plain"}
:body (str greeting " " (get ctx :greeter/msg))}]]}]}
Security considerations
End-user header overrides
Passage usually sits between the client and the server; any HTTP request header from the client is passed on to the provider. Thus, sensitive headers which, for example, are used to allow access MUST be filtered out using the request interceptor. For example:
[request update :headers dissoc "x-user-id"]
⚠ Headers are 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 and authorization tokens handled by Passage 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 are case-insensitive and always lower case in a request object, so when removing a header using dissoc use the lower case value! ⚠
Development
Building Passage from source
Passage can be built from source by running
make passage.jar
Running the test suite
To run the test suite, run:
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):
ifconfig lo0 alias 127.0.0.2 up
LICENSE
MIT License
Copyright (c) 2025 Jomco BV
Copyright (c) 2025 Topsector Logistiek
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.