Passage is a customizable HTTP pipeline.
  • Clojure 97.5%
  • Makefile 2.5%
Find a file
Remco van 't Veer 0d291ae538
All checks were successful
/ check (push) Successful in 1m19s
Security update CVE-2025-67735
Override netty dependency from aleph.
2026-01-08 12:04:04 +01:00
.clj-kondo Correct copyright statements 2025-10-17 17:12:21 +02:00
.forgejo/workflows Workaround guix forgejo-runner bug 2025-11-11 10:46:15 +01:00
dev Correct copyright statements 2025-10-17 17:12:21 +02:00
doc Add some documentation 2025-10-23 11:35:47 +02:00
LICENSES Extracted from bdi-stack 2025-10-15 17:44:48 +02:00
resources Correct copyright statements 2025-10-17 17:12:21 +02:00
src Log exception data at level debug 2025-12-17 11:42:18 +01:00
test Use (is (thrown-with-msg? ..)) special form in exception tests 2025-12-17 14:11:33 +01:00
test-resources Interceptor constructors 2025-11-06 17:23:14 +01:00
.gitignore Add generated interceptor documentation to README.md 2025-11-13 10:51:05 +01:00
.manifest.scm Use OpenJDK 21 for CI 2025-11-11 10:48:29 +01:00
deps.edn Security update CVE-2025-67735 2026-01-08 12:04:04 +01:00
Makefile Fix generating README 2025-11-14 17:15:16 +01:00
README.md Add documentation of matching query string parameters 2025-12-17 11:44:57 +01:00
README.src.md Add documentation of matching query string parameters 2025-12-17 11:44:57 +01:00
tests.edn Correct copyright statements 2025-10-17 17:12:21 +02:00

Passage — Flexible HTTP Processing

Passage logo

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_FILE

    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 8080.

Rules

The rules file is parsed using 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

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
  • :ns-alias a map for aliases to namespaces for interceptor lookups

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, 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:

  • 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 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.