This is an automated email from the ASF dual-hosted git repository.

oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-examples.git


The following commit(s) were added to refs/heads/main by this push:
     new bd58c2b4 CAMEL-23305: Add camel-spiffe example (workload identity with 
SPIFFE and SPIRE) (#260)
bd58c2b4 is described below

commit bd58c2b49fb5f483f5b2520552d630fcb1b89f54
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Sep 7 16:18:43 2026 +0200

    CAMEL-23305: Add camel-spiffe example (workload identity with SPIFFE and 
SPIRE) (#260)
    
    A Docker Compose example for the camel-spiffe component: a SPIRE server
    and agent issue identities to four Camel Main applications, which
    authenticate each other with JWT-SVIDs (fetchJwtSvid / validateJwtSvid),
    authorize by SPIFFE ID with per-route allow-lists in a shared route
    configuration, make a second hop with their own identity, and log their
    rotating X.509-SVIDs (fetchX509Svid).
    
    Co-authored-by: Claude Fable 5.1 <[email protected]>
---
 README.adoc                                        |   4 +-
 pom.xml                                            |   1 +
 spiffe/README.adoc                                 | 373 +++++++++++++++++++++
 spiffe/compose.yaml                                | 107 ++++++
 spiffe/pom.xml                                     | 160 +++++++++
 spiffe/spire/Dockerfile                            |  32 ++
 spiffe/spire/agent.conf                            |  43 +++
 spiffe/spire/entrypoint.sh                         |  58 ++++
 spiffe/spire/server.conf                           |  55 +++
 spiffe/src/main/docker/Dockerfile                  |  30 ++
 .../camel/example/spiffe/IdentityRoutes.java       |  49 +++
 .../camel/example/spiffe/X509SvidSummary.java      |  73 ++++
 .../example/spiffe/backend/BackendApplication.java |  44 +++
 .../example/spiffe/backend/BackendRoutes.java      |  85 +++++
 .../camel/example/spiffe/backend/OrderService.java |  63 ++++
 .../spiffe/frontend/FrontendApplication.java       |  41 +++
 .../example/spiffe/frontend/FrontendRoutes.java    |  70 ++++
 .../spiffe/inventory/InventoryApplication.java     |  42 +++
 .../example/spiffe/inventory/InventoryRoutes.java  |  38 +++
 .../example/spiffe/inventory/StockService.java     |  34 ++
 .../camel/example/spiffe/policy/AllowList.java     |  49 +++
 .../camel/example/spiffe/policy/AuditTrail.java    |  63 ++++
 .../camel/example/spiffe/policy/BearerToken.java   |  38 +++
 .../example/spiffe/policy/RejectionReason.java     |  37 ++
 .../spiffe/policy/WorkloadIdentityPolicy.java      | 101 ++++++
 spiffe/src/main/resources/application.properties   |  52 +++
 spiffe/src/main/resources/log4j2.properties        |  23 ++
 .../camel/example/spiffe/IdentityRoutesTest.java   | 103 ++++++
 .../example/spiffe/backend/BackendRoutesTest.java  | 222 ++++++++++++
 .../spiffe/frontend/FrontendRoutesTest.java        | 143 ++++++++
 .../spiffe/inventory/InventoryRoutesTest.java      | 109 ++++++
 spiffe/src/test/resources/frontend-svid.pem        |  13 +
 32 files changed, 2354 insertions(+), 1 deletion(-)

diff --git a/README.adoc b/README.adoc
index 075bc2b9..e8bab9e0 100644
--- a/README.adoc
+++ b/README.adoc
@@ -28,7 +28,7 @@ readme's instructions.
 == Examples
 
 // examples: START
-Number of Examples: 72 (2 deprecated)
+Number of Examples: 71 (0 deprecated)
 
 [width="100%",cols="4,2,4",options="header"]
 |===
@@ -162,6 +162,8 @@ Number of Examples: 72 (2 deprecated)
 
 | link:vault/google-secret-manager-reloading/README.adoc[Google Secret Manager 
Reloading] (google-secret-manager-reloading) | Security | An example for 
showing Google Secret Manager Camel component with reloading
 
+| link:spiffe/README.adoc[SPIFFE] (spiffe) | Security | An example for showing 
workload identity with the Camel SPIFFE component (JWT-SVID and X.509-SVID 
issued by SPIRE)
+
 | link:salesforce-consumer/README.adoc[Salesforce Consumer] 
(salesforce-consumer) | Social | An example that uses Salesforce Rest Streaming 
API
 
 | link:telegram/README.adoc[Telegram] (telegram) | Social | An example that 
uses Telegram API
diff --git a/pom.xml b/pom.xml
index 025bc3ee..e27ab5be 100644
--- a/pom.xml
+++ b/pom.xml
@@ -127,6 +127,7 @@
         <module>routetemplate-xml</module>
         <module>routes-configuration</module>
         <module>salesforce-consumer</module>
+        <module>spiffe</module>
         <module>spring</module>
         <module>spring-pulsar</module>
         <module>spring-xquery</module>
diff --git a/spiffe/README.adoc b/spiffe/README.adoc
new file mode 100644
index 00000000..23f5fb15
--- /dev/null
+++ b/spiffe/README.adoc
@@ -0,0 +1,373 @@
+== Camel Example SPIFFE
+
+This example shows how to give Camel applications a cryptographic workload 
identity with the
+https://camel.apache.org/components/next/spiffe-component.html[Camel SPIFFE 
component] (`camel-spiffe`),
+and how a small chain of services uses that identity to authenticate and 
authorize their calls without any shared
+secret, password or API key in the code, in the configuration or on disk.
+
+https://spiffe.io/[SPIFFE] (Secure Production Identity Framework For Everyone) 
names a workload with a SPIFFE ID
+such as `spiffe://example.org/frontend` and proves that name with two kinds of 
SPIFFE Verifiable Identity Documents
+(SVIDs): an X.509-SVID (a certificate) and a JWT-SVID (a token). 
https://spiffe.io/docs/latest/spire-about/[SPIRE]
+is the reference implementation: a SPIRE server issues the documents and a 
SPIRE agent hands them out to the
+workloads through the SPIFFE Workload API, once it has _attested_ them, that 
is, once it has checked who they are.
+In this example the agent attests a workload by the Unix user it runs as.
+
+=== What the example does
+
+Four Camel applications and a SPIRE deployment run with Docker Compose, all in 
the trust domain `example.org`:
+
+----
+                    
+---------------------------------------------------------------------+
+                    |  spire: SPIRE server + SPIRE agent                       
           |
+                    |                                                          
           |
+                    |  registration entries                                    
           |
+                    |    unix:uid:1001  ->  spiffe://example.org/frontend      
           |
+                    |    unix:uid:1002  ->  spiffe://example.org/backend       
           |
+                    |    unix:uid:1003  ->  spiffe://example.org/auditor       
           |
+                    |    unix:uid:1004  ->  spiffe://example.org/inventory     
           |
+                    
+----------------------------------+----------------------------------+
+                                                       |  SPIFFE Workload API 
(Unix socket)
+          
+-------------------+----------------------+-+-------------------------+
+          |                   |                      |                         
  |
++---------+--------+ +--------+---------+ +----------+---------+ 
+---------------+--------+
+| frontend         | | auditor          | | backend            | | inventory   
           |
+| uid 1001         | | uid 1003         | | uid 1002           | | uid 1004    
           |
+|                  | |                  | |                    | |             
           |
+| fetchJwtSvid     | | fetchJwtSvid     | | validateJwtSvid    | | 
validateJwtSvid        |
+| fetchX509Svid    | | fetchX509Svid    | | fetchJwtSvid       | | 
fetchX509Svid          |
+|                  | |                  | | fetchX509Svid      | |             
           |
++---------+--------+ +--------+---------+ +----+----------+----+ 
+---------------+--------+
+          |                   |               ^          |                     
  ^
+          |  GET /api/orders  |               |          |  GET /api/stock     
  |
+          |  GET /api/audit   |               |          |  Authorization: 
Bearer JWT (backend)
+          |  Authorization: Bearer JWT        |          |  X-On-Behalf-Of: 
<caller>
+          +-------------------+---------------+          
+-----------------------+
+                 frontend: orders 200, audit 403
+                 auditor:  orders 403, audit 200
+----
+
+* The *backend* exposes `GET /api/orders` and `GET /api/audit`. Both routes 
use the same
+  <<the-workload-identity-policy,workload identity policy>>: every request 
must carry a JWT-SVID as bearer token,
+  which the backend hands to the Workload API (`validateJwtSvid`) to check its 
signature, its expiry and that it was
+  minted for the backend (the _audience_ of the token). The SPIFFE ID of the 
caller comes back in the
+  `CamelSpiffeSpiffeId` header, and the allow-list of the route decides 
whether that caller may use it.
+* The *inventory* is the second hop. To serve the orders, the backend asks it 
for the stock levels with a JWT-SVID
+  of its own (`fetchJwtSvid`, this time with the inventory as audience) and 
tells it on whose behalf it asks. The
+  inventory uses the very same policy: it accepts the backend, and nobody else.
+* The *frontend* asks the Workload API every 10 seconds for a JWT-SVID with 
the backend as audience
+  (`fetchJwtSvid`) and reads the orders with it, completed with the stock 
levels (HTTP 200). Every 30 seconds it
+  also tries to read the audit trail, which it is not allowed to (HTTP 403). 
Every 45 seconds it asks for a token
+  minted for some other service and presents that one instead, which the 
backend rejects (HTTP 401).
+* The *auditor* is the very same code and image as the frontend, but it runs 
as another Unix user. The SPIRE agent
+  therefore gives it another identity, `spiffe://example.org/auditor`, with 
the opposite permissions: it may read
+  the audit trail of the backend (HTTP 200), that is, who called what and with 
which outcome, but not the orders
+  (HTTP 403).
+* All four applications also fetch their X.509-SVID once a minute 
(`fetchX509Svid`) and log a summary of the
+  certificate: the SPIFFE ID (a URI subject alternative name), the serial 
number and the validity period. The
+  certificates are short-lived (10 minutes) and the SPIRE agent rotates them 
before they expire, so the summary
+  changes over time without the applications doing anything about it.
+
+The identities are not configured anywhere in the applications: they come from 
the `spire` container, which
+registers the workloads at startup (see `spire/entrypoint.sh`). What each 
identity may do is a few lines of
+`application.properties`:
+
+[source,properties]
+----
+backend.allow.orders = spiffe://example.org/frontend
+backend.allow.audit = spiffe://example.org/auditor
+inventory.allow.stock = spiffe://example.org/backend
+----
+
+[width="100%",cols="1,1,3,5",options="header"]
+|===
+| Container | Unix uid | SPIFFE ID | What it may do
+
+| `frontend` | 1001 | `spiffe://example.org/frontend` | `GET /api/orders` on 
the backend (200); the audit trail is denied (403)
+| `auditor` | 1003 | `spiffe://example.org/auditor` | `GET /api/audit` on the 
backend (200); the orders are denied (403)
+| `backend` | 1002 | `spiffe://example.org/backend` | serves the orders and 
the audit trail, `GET /api/stock` on the inventory (200)
+| `inventory` | 1004 | `spiffe://example.org/inventory` | serves the stock 
levels to the backend
+|===
+
+[#the-workload-identity-policy]
+=== The workload identity policy
+
+`WorkloadIdentityPolicy` is a Camel
+https://camel.apache.org/manual/route-configuration.html[route configuration]: 
the checks it contains run before
+the first step of every route that opts in with 
`routeConfigurationId(WorkloadIdentityPolicy.ID)`, so the routes
+of the backend and of the inventory contain business logic only. It is 
instantiated once per service
+(`new WorkloadIdentityPolicy("backend")`), which is how it finds the audience 
of the service and the allow-lists of
+its routes in the configuration.
+
+[source,java]
+----
+// whatever goes wrong while checking the token means that the caller is not 
authenticated: HTTP 401
+policy.onException(JwtSvidException.class, IllegalArgumentException.class)
+        .handled(true)
+        .setBody(method(RejectionReason.class, "of"))
+        .bean(auditTrail, "record(${routeId}, null, 'rejected', ${body})")
+        .log(LoggingLevel.WARN, "Rejected request to ${routeId}: ${body}")
+        .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(401))
+        .setBody(simple("401 Unauthorized: ${body}"))
+        .removeHeaders("CamelSpiffe*");
+
+// runs before the first step of every route that uses this policy
+policy.interceptFrom()
+        // authentication: the bearer token must be a JWT-SVID minted for this 
service
+        .setHeader(SpiffeConstants.TOKEN).method(BearerToken.class, "extract")
+        .removeHeader("Authorization")
+        .to("spiffe:" + service + "?operation=validateJwtSvid&audience={{" + 
service + ".audience}}")
+        // authorization: the caller must be on the allow-list of the route
+        .choice()
+            .when(method(allowList, "isAllowed(${routeId}, 
${header.CamelSpiffeSpiffeId})"))
+                .bean(auditTrail, "record(${routeId}, 
${header.CamelSpiffeSpiffeId}, 'allowed', null)")
+                .log("Authenticated caller ${header.CamelSpiffeSpiffeId}, 
allowed to call ${routeId}")
+            .otherwise()
+                .bean(auditTrail, "record(${routeId}, 
${header.CamelSpiffeSpiffeId}, 'denied', null)")
+                .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(403))
+                .setBody(simple("403 Forbidden: ${header.CamelSpiffeSpiffeId} 
is not allowed to call ${routeId}"))
+                .removeHeaders("CamelSpiffe*")
+                // the route itself does not run
+                .stop()
+        .end();
+----
+
+`validateJwtSvid` takes the token from the `CamelSpiffeToken` header. The 
message body becomes the validated
+`io.spiffe.svid.jwtsvid.JwtSvid` and the SPIFFE ID of the caller is set as the 
`CamelSpiffeSpiffeId` header. A
+failed validation throws an `io.spiffe.exception.JwtSvidException`, whose 
cause says why (expired, wrong audience,
+unknown key, ...); the policy puts that reason in the HTTP 401 response. 
`AllowList` looks up
+`<service>.allow.<route id>` in the configuration, and `AuditTrail` keeps the 
last decisions of the policy, which
+the backend exposes on `/api/audit`.
+
+=== The Camel routes
+
+* The backend (`BackendRoutes`) serves the orders, and makes the second hop 
with its own identity:
+`fetchJwtSvid` with the inventory as audience. The caller it is serving 
travels along in a header, for the audit
+trail of the inventory, which trusts that header only because the backend 
itself is authenticated and allowed to
+call it. Nothing of the second hop is sent back to the caller.
++
+[source,java]
+----
+from("platform-http:/api/orders?httpMethodRestrict=GET").routeId("orders")
+    .routeConfigurationId(WorkloadIdentityPolicy.ID)
+    // the policy has authenticated and authorized the caller by the time the 
route starts
+    .bean(OrderService.class, "listOrders")
+    .setProperty("orders", body())
+    .setProperty("onBehalfOf", header(SpiffeConstants.SPIFFE_ID))
+    .to("direct:stockLevels")
+    .bean(OrderService.class, "withStock")
+    .marshal().json()
+    .removeHeaders("CamelSpiffe*");
+
+from("direct:stockLevels").routeId("stock-levels")
+    
.to("spiffe:backend?operation=fetchJwtSvid&audience={{inventory.audience}}")
+    .setHeader("Authorization", simple("Bearer ${body}"))
+    .setHeader("X-On-Behalf-Of", exchangeProperty("onBehalfOf"))
+    .setBody(simple("${null}"))
+    .removeHeaders("CamelHttp*")
+    .removeHeaders("CamelSpiffe*")
+    
.to("http://{{inventory.host}}:{{inventory.port}}/api/stock?httpMethod=GET";)
+    .removeHeader("Authorization")
+    .removeHeader("X-On-Behalf-Of")
+    .unmarshal().json(Map.class);
+----
+
+* The inventory (`InventoryRoutes`) is the same policy applied to another 
service:
++
+[source,java]
+----
+from("platform-http:/api/stock?httpMethodRestrict=GET").routeId("stock")
+    .routeConfigurationId(WorkloadIdentityPolicy.ID)
+    .log("Serving the stock levels to ${header.CamelSpiffeSpiffeId} on behalf 
of ${header.X-On-Behalf-Of}")
+    .bean(StockService.class, "levels")
+    .marshal().json()
+    .removeHeaders("CamelSpiffe*");
+----
+
+* The frontend (`FrontendRoutes`) gets its tokens with `fetchJwtSvid`: the 
message body becomes the token, the
+SPIFFE ID and the expiry of the token are set as the `CamelSpiffeSpiffeId` and 
`CamelSpiffeExpiry` headers. The
+timers only set the path to call; the `wrong-audience` route also sets the 
`CamelSpiffeAudience` header, which
+overrides the audience of the endpoint for that message.
++
+[source,java]
+----
+from("timer:orders?period={{frontend.period}}").routeId("orders")
+    .setHeader(Exchange.HTTP_PATH, constant("/api/orders"))
+    .to("direct:callBackend");
+
+from("direct:callBackend").routeId("call-backend")
+    .to("spiffe:frontend?operation=fetchJwtSvid&audience={{backend.audience}}")
+    .log("Fetched a JWT-SVID for ${header.CamelSpiffeSpiffeId} (valid until 
${header.CamelSpiffeExpiry})")
+    .setHeader("Authorization", simple("Bearer ${body}"))
+    .setBody(simple("${null}"))
+    .removeHeaders("CamelSpiffe*")
+    
.to("http://{{backend.host}}:{{backend.port}}?httpMethod=GET&throwExceptionOnFailure=false";)
+    .log("GET ${header.CamelHttpPath} answered HTTP 
${header.CamelHttpResponseCode}: ${body}");
+----
+
+* All applications run `IdentityRoutes`, where `fetchX509Svid` makes the 
message body an
+  `io.spiffe.svid.x509svid.X509Svid` with the certificate chain, the private 
key and the SPIFFE ID of the
+  workload. `X509SvidSummary` describes the leaf certificate, and only the 
certificate: the private key is never
+  logged.
+
+The component finds the Workload API through the standard 
`SPIFFE_ENDPOINT_SOCKET` environment variable, which
+`compose.yaml` sets for each application. The 
`camel.component.spiffe.spiffe-socket-path` option in
+`application.properties` (commented out) does the same from the configuration.
+
+=== Build
+
+The example is built with Maven:
+
+[source,sh]
+----
+$ mvn package
+----
+
+This also runs the unit tests, which do not need SPIRE (see below), and copies 
the runtime dependencies to
+`target/lib`, from where `src/main/docker/Dockerfile` picks them up.
+
+=== How to run
+
+You need Docker with Docker Compose. Build the images and start everything 
with:
+
+[source,sh]
+----
+$ docker compose up --build
+----
+
+The `spire` container starts a SPIRE server, registers the four workloads, 
then starts a SPIRE agent that joins
+the server with a one-time token. Once the agent serves the Workload API, the 
inventory and the backend start, then
+the frontend and the auditor. Within a few seconds the logs show the frontend 
getting the orders with their stock
+levels, the inventory serving the backend on behalf of the frontend, the 
auditor being turned away from the orders
+but reading the audit trail and, now and then, the frontend being rejected 
when it presents a token minted for
+another audience:
+
+----
+spire-1     | Registering spiffe://example.org/frontend for the workload 
running with uid 1001
+spire-1     | Registering spiffe://example.org/backend for the workload 
running with uid 1002
+spire-1     | Registering spiffe://example.org/auditor for the workload 
running with uid 1003
+spire-1     | Registering spiffe://example.org/inventory for the workload 
running with uid 1004
+spire-1     | time="..." level=info msg="Node attestation was successful" ...
+frontend-1  | 17:27:23.6 [timer://orders] call-backend INFO  Fetched a 
JWT-SVID for spiffe://example.org/frontend (valid until ...)
+backend-1   | 17:27:23.7 [worker-thread-1] orders INFO  Authenticated caller 
spiffe://example.org/frontend, allowed to call orders
+inventory-1 | 17:27:23.9 [worker-thread-0] stock INFO  Authenticated caller 
spiffe://example.org/backend, allowed to call stock
+inventory-1 | 17:27:23.9 [worker-thread-0] stock INFO  Serving the stock 
levels to spiffe://example.org/backend on behalf of 
spiffe://example.org/frontend
+frontend-1  | 17:27:24.0 [timer://orders] call-backend INFO  GET /api/orders 
answered HTTP 200: 
{"caller":"spiffe://example.org/frontend","orders":[{"id":1001,"item":"Camel in 
Action, 2nd edition","quantity":2,"inStock":true},{"id":1002,"item":"Enterprise 
Integration Patterns","quantity":1,"inStock":false},...
+backend-1   | 17:27:23.7 [worker-thread-0] orders WARN  Authenticated caller 
spiffe://example.org/auditor is not allowed to call orders
+auditor-1   | 17:27:23.7 [timer://orders] call-backend INFO  GET /api/orders 
answered HTTP 403: 403 Forbidden: spiffe://example.org/auditor is not allowed 
to call orders
+auditor-1   | 17:27:52.3 [timer://audit] call-backend INFO  GET /api/audit 
answered HTTP 200: 
{"service":"backend","decisions":[{"time":"2026-09-03T17:27:23Z","route":"orders","caller":"spiffe://example.org/auditor","outcome":"denied"},{"time":"2026-09-03T17:27:23Z","route":"orders","caller":"spiffe://example.org/frontend","outcome":"allowed"},...
+frontend-1  | 17:27:52.3 [timer://audit] call-backend INFO  GET /api/audit 
answered HTTP 403: 403 Forbidden: spiffe://example.org/frontend is not allowed 
to call audit
+frontend-1  | 17:28:07.3 [timer://wrongAudience] wrong-audience INFO  Asking 
for a JWT-SVID with the wrong audience, the backend should reject it
+backend-1   | 17:28:07.3 [worker-thread-10] orders WARN  Rejected request to 
orders: Error validating JWT SVID: INVALID_ARGUMENT: expected audience in 
["spiffe://example.org/backend"] 
(audience=["spiffe://example.org/some-other-service"])
+frontend-1  | 17:28:07.3 [timer://wrongAudience] call-backend INFO  GET 
/api/orders answered HTTP 401: 401 Unauthorized: Error validating JWT SVID: 
INVALID_ARGUMENT: expected audience in ["spiffe://example.org/backend"] 
(audience=["spiffe://example.org/some-other-service"])
+inventory-1 | 17:28:22.3 [timer://identity] identity INFO  X.509-SVID of 
spiffe://example.org/inventory
+inventory-1 |     serial number : 1b9d0f4c7d0e6d2a5f7c3b1e9a8d6c4f
+inventory-1 |     subject       : O=SPIRE, C=US
+inventory-1 |     issuer        : SERIALNUMBER=..., CN=example.org, O=Apache 
Camel, C=US
+inventory-1 |     valid from    : 2026-09-03T17:27:12Z
+inventory-1 |     valid until   : 2026-09-03T17:37:22Z
+inventory-1 |     URI SANs      : [spiffe://example.org/inventory]
+inventory-1 |     chain length  : 1 certificate(s)
+----
+
+A few things to try while it runs:
+
+* Call the services yourself. You have no SPIFFE identity and no token, so 
both turn you away (the inventory is
+  published on port 8081):
++
+[source,sh]
+----
+$ curl -i http://localhost:8080/api/orders
+HTTP/1.1 401 Unauthorized
+...
+401 Unauthorized: no bearer token in the Authorization header
+
+$ curl -i http://localhost:8081/api/stock
+HTTP/1.1 401 Unauthorized
+...
+----
+
+* Look at the registration entries, that is, at who is who in the trust domain:
++
+[source,sh]
+----
+$ docker compose exec spire /opt/spire/bin/spire-server entry show
+----
+
+* Watch the X.509-SVID of an application being rotated. The SPIRE agent renews 
a certificate halfway through its
+  lifetime, so about every five minutes the serial number and the validity 
period in the summary change, while the
+  SPIFFE ID stays the same:
++
+[source,sh]
+----
+$ docker compose logs -f frontend | grep -A 8 "X.509-SVID of"
+----
+
+* Read why a token was rejected from the point of view of the SPIRE agent, 
which is what actually validates it:
++
+[source,sh]
+----
+$ docker compose logs spire | grep "Failed to validate JWT"
+----
+
+* Change who may do what: add `spiffe://example.org/auditor` to 
`backend.allow.orders` in
+  `src/main/resources/application.properties`, then rebuild and restart the 
backend with
+  `mvn package -DskipTests && docker compose up --build -d backend`. Nothing 
changes in the auditor, yet its next
+  call gets the orders back. Or add `spiffe://example.org/frontend` to 
`inventory.allow.stock` and see that the
+  frontend still cannot call the inventory: its tokens are minted for the 
backend, not for the inventory.
+
+Stop everything, and remove the containers and the volume with the Workload 
API socket, with:
+
+[source,sh]
+----
+$ docker compose down -v
+----
+
+NOTE: The `spire` container is a shortcut for the purpose of this example: it 
runs the SPIRE server and the SPIRE
+agent side by side and as root, and the Camel applications share its PID 
namespace so that the agent can attest
+them with the `unix` workload attestor. A real deployment runs the server on 
its own, one agent per node and,
+on Kubernetes, attests the workloads by their pod and service account instead 
of their Unix user.
+
+=== Running the tests
+
+[source,sh]
+----
+$ mvn test
+----
+
+The tests do not need SPIRE. Each test class binds a Mockito mock of 
`io.spiffe.workloadapi.WorkloadApiClient` to
+the Camel registry with `@BindToRegistry`, and the SPIFFE component autowires 
the single client it finds there (its
+`workloadApiClient` option). The routes under test are therefore exactly the 
ones that run in the containers, only
+the SPIRE agent is replaced by a fake that mints, validates or refuses SVIDs 
as the test needs. The backend and the
+inventory are tested over HTTP, on the embedded server of Camel Main, so the 
policy runs exactly as in the
+containers; the backend test also stubs the inventory on that server to check 
the second hop.
+
+=== Running the applications outside Docker
+
+The applications can also run directly on your machine with `mvn camel:run`, 
as long as a SPIRE agent (or any other
+SPIFFE Workload API) is reachable and has a registration entry for the process 
that runs them:
+
+[source,sh]
+----
+$ export SPIFFE_ENDPOINT_SOCKET=unix:///tmp/spire-agent/public/api.sock
+$ mvn camel:run -Dcamel.server.port=8081 
-Dcamel.mainClass=org.apache.camel.example.spiffe.inventory.InventoryApplication
+$ mvn camel:run -Dinventory.host=localhost -Dinventory.port=8081
+$ mvn camel:run -Dbackend.host=localhost 
-Dcamel.mainClass=org.apache.camel.example.spiffe.frontend.FrontendApplication
+----
+
+Keep in mind that all the applications then run as the same Unix user, hence 
with the same SPIFFE ID, unless the
+Workload API you use attests them differently.
+
+The Workload API is a gRPC service on a Unix domain socket, for which the 
SPIFFE Java library needs a native
+transport. The `pom.xml` of the example includes the Linux one 
(`io.spiffe:grpc-netty-linux`), which is what the
+containers need. On macOS replace it with `io.spiffe:grpc-netty-macos` or, on 
Apple silicon,
+`io.spiffe:grpc-netty-macos-aarch64`.
+
+=== Help and contributions
+
+If you hit any problem using Camel or have some feedback, then please
+https://camel.apache.org/community/support/[let us know].
+
+We also love contributors, so
+https://camel.apache.org/community/contributing/[get involved] :-)
+
+The Camel riders!
diff --git a/spiffe/compose.yaml b/spiffe/compose.yaml
new file mode 100644
index 00000000..c5b250d6
--- /dev/null
+++ b/spiffe/compose.yaml
@@ -0,0 +1,107 @@
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements.  See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License.  You may obtain a copy of the License at
+##
+##      http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+
+# everything the three Camel applications have in common: the same image, the 
SPIFFE Workload API socket of the
+# SPIRE agent, and the PID namespace of the SPIRE container (the agent attests 
a workload by looking up the process
+# that connects to the Workload API, so it must be able to see it)
+x-camel-application: &camel-application
+  build:
+    context: .
+    dockerfile: src/main/docker/Dockerfile
+  image: camel-example-spiffe
+  pid: "service:spire"
+  volumes:
+    - spire-sockets:/run/spire/sockets
+  depends_on:
+    spire:
+      condition: service_healthy
+
+services:
+
+  # SPIRE server and agent in one container (see spire/entrypoint.sh): the 
server issues the identities,
+  # the agent attests the workloads and hands them their SVIDs through the 
SPIFFE Workload API
+  spire:
+    build: ./spire
+    volumes:
+      - spire-sockets:/run/spire/sockets
+    healthcheck:
+      test: ["CMD", "/opt/spire/bin/spire-agent", "healthcheck", 
"-socketPath", "/run/spire/sockets/agent.sock"]
+      interval: 5s
+      timeout: 3s
+      retries: 30
+      start_period: 5s
+
+  # the second hop: an HTTP API that only the backend may call. uid 1004 is 
registered as
+  # spiffe://example.org/inventory
+  inventory:
+    <<: *camel-application
+    command: ["org.apache.camel.example.spiffe.inventory.InventoryApplication"]
+    user: "1004:1004"
+    environment:
+      CAMEL_MAIN_NAME: inventory
+      SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock
+    ports:
+      - "8081:8080"
+
+  # the HTTP API the clients talk to, which calls the inventory with its own 
identity.
+  # uid 1002 is registered as spiffe://example.org/backend
+  backend:
+    <<: *camel-application
+    command: ["org.apache.camel.example.spiffe.backend.BackendApplication"]
+    user: "1002:1002"
+    environment:
+      CAMEL_MAIN_NAME: backend
+      SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock
+    ports:
+      - "8080:8080"
+    depends_on:
+      spire:
+        condition: service_healthy
+      inventory:
+        condition: service_started
+
+  # the client that is allowed to read the orders (but not the audit trail): 
uid 1001 is registered as
+  # spiffe://example.org/frontend
+  frontend:
+    <<: *camel-application
+    command: ["org.apache.camel.example.spiffe.frontend.FrontendApplication"]
+    user: "1001:1001"
+    environment:
+      CAMEL_MAIN_NAME: frontend
+      SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock
+    depends_on:
+      spire:
+        condition: service_healthy
+      backend:
+        condition: service_started
+
+  # the very same code and image as the frontend, but another Unix user, hence 
another identity: uid 1003 is
+  # registered as spiffe://example.org/auditor, which may read the audit trail 
but not the orders
+  auditor:
+    <<: *camel-application
+    command: ["org.apache.camel.example.spiffe.frontend.FrontendApplication"]
+    user: "1003:1003"
+    environment:
+      CAMEL_MAIN_NAME: auditor
+      SPIFFE_ENDPOINT_SOCKET: unix:///run/spire/sockets/agent.sock
+    depends_on:
+      spire:
+        condition: service_healthy
+      backend:
+        condition: service_started
+
+volumes:
+  # the Unix domain socket of the SPIFFE Workload API, shared between the 
SPIRE agent and the workloads
+  spire-sockets:
diff --git a/spiffe/pom.xml b/spiffe/pom.xml
new file mode 100644
index 00000000..0d5a136c
--- /dev/null
+++ b/spiffe/pom.xml
@@ -0,0 +1,160 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+    Licensed to the Apache Software Foundation (ASF) under one or more
+    contributor license agreements.  See the NOTICE file distributed with
+    this work for additional information regarding copyright ownership.
+    The ASF licenses this file to You under the Apache License, Version 2.0
+    (the "License"); you may not use this file except in compliance with
+    the License.  You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/maven-v4_0_0.xsd";>
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.camel.example</groupId>
+        <artifactId>camel-examples</artifactId>
+        <version>4.23.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>camel-example-spiffe</artifactId>
+    <packaging>jar</packaging>
+    <name>Camel :: Example :: SPIFFE</name>
+    <description>An example for showing workload identity with the Camel 
SPIFFE component (JWT-SVID and X.509-SVID issued by SPIRE)</description>
+
+    <properties>
+        <category>Security</category>
+        <title>SPIFFE</title>
+        
<maven-dependency-plugin-version>3.8.1</maven-dependency-plugin-version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <!-- Add Camel BOM -->
+            <dependency>
+                <groupId>org.apache.camel</groupId>
+                <artifactId>camel-bom</artifactId>
+                <version>${camel.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-main</artifactId>
+        </dependency>
+        <!-- talks to the SPIFFE Workload API (SPIRE agent) -->
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-spiffe</artifactId>
+        </dependency>
+        <!-- the Workload API is a gRPC service on a Unix domain socket; 
java-spiffe needs a native transport for that.
+             This is the Linux one, which is what runs inside the containers 
of this example. When running the
+             applications directly on macOS use io.spiffe:grpc-netty-macos or 
io.spiffe:grpc-netty-macos-aarch64 instead. -->
+        <dependency>
+            <groupId>io.spiffe</groupId>
+            <artifactId>grpc-netty-linux</artifactId>
+            <version>${java-spiffe-version}</version>
+            <scope>runtime</scope>
+        </dependency>
+        <!-- embedded HTTP server for the backend -->
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-platform-http-main</artifactId>
+        </dependency>
+        <!-- HTTP client for the frontend -->
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-http</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-timer</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-jackson</artifactId>
+        </dependency>
+
+        <!-- logging -->
+        <dependency>
+            <groupId>org.apache.logging.log4j</groupId>
+            <artifactId>log4j-core</artifactId>
+            <version>${log4j2-version}</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.logging.log4j</groupId>
+            <artifactId>log4j-slf4j2-impl</artifactId>
+            <version>${log4j2-version}</version>
+            <scope>runtime</scope>
+        </dependency>
+
+        <!-- for testing -->
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-test-main-junit6</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito-version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <!-- to run via mvn camel:run (the backend by default, use 
-Dcamel.mainClass=... for the frontend) -->
+            <plugin>
+                <groupId>org.apache.camel</groupId>
+                <artifactId>camel-maven-plugin</artifactId>
+                <version>${camel.version}</version>
+                <configuration>
+                    <logClasspath>false</logClasspath>
+                    
<mainClass>org.apache.camel.example.spiffe.backend.BackendApplication</mainClass>
+                </configuration>
+            </plugin>
+
+            <!-- copy the runtime dependencies to target/lib so the container 
image can pick them up -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-dependency-plugin</artifactId>
+                <version>${maven-dependency-plugin-version}</version>
+                <executions>
+                    <execution>
+                        <id>copy-dependencies</id>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>copy-dependencies</goal>
+                        </goals>
+                        <configuration>
+                            
<outputDirectory>${project.build.directory}/lib</outputDirectory>
+                            <includeScope>runtime</includeScope>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>
diff --git a/spiffe/spire/Dockerfile b/spiffe/spire/Dockerfile
new file mode 100644
index 00000000..abc6e99a
--- /dev/null
+++ b/spiffe/spire/Dockerfile
@@ -0,0 +1,32 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# The official SPIRE images are built from scratch and have no shell. This 
image copies their static binaries
+# into a small Alpine image, so that entrypoint.sh can bootstrap a complete 
SPIRE deployment for the example:
+# a server, an agent and the registration entries of the workloads.
+FROM ghcr.io/spiffe/spire-server:1.15.3 AS spire-server
+FROM ghcr.io/spiffe/spire-agent:1.15.3 AS spire-agent
+
+FROM alpine:3.22
+
+COPY --from=spire-server /opt/spire/bin/spire-server 
/opt/spire/bin/spire-server
+COPY --from=spire-agent /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent
+COPY server.conf agent.conf /opt/spire/conf/
+COPY entrypoint.sh /opt/spire/entrypoint.sh
+
+RUN chmod 0755 /opt/spire/entrypoint.sh \
+    && mkdir -p /opt/spire/data/server /opt/spire/data/agent 
/run/spire/sockets /tmp/spire-server/private
+
+ENTRYPOINT ["/opt/spire/entrypoint.sh"]
diff --git a/spiffe/spire/agent.conf b/spiffe/spire/agent.conf
new file mode 100644
index 00000000..a87d2c94
--- /dev/null
+++ b/spiffe/spire/agent.conf
@@ -0,0 +1,43 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# SPIRE agent configuration, see 
https://github.com/spiffe/spire/blob/main/doc/spire_agent.md
+agent {
+    data_dir = "/opt/spire/data/agent"
+    log_level = "INFO"
+    server_address = "127.0.0.1"
+    server_port = "8081"
+    # the SPIFFE Workload API: the Camel applications connect here 
(SPIFFE_ENDPOINT_SOCKET in compose.yaml)
+    socket_path = "/run/spire/sockets/agent.sock"
+    # the CA bundle of the server, exported by entrypoint.sh before the agent 
starts
+    trust_bundle_path = "/opt/spire/data/bootstrap.crt"
+    trust_domain = "example.org"
+}
+
+plugins {
+    KeyManager "memory" {
+        plugin_data {}
+    }
+
+    NodeAttestor "join_token" {
+        plugin_data {}
+    }
+
+    # attests the workloads that connect to the Workload API by their Unix 
user id, group id, and so on.
+    # The registration entries of this example use the unix:uid selector (see 
entrypoint.sh)
+    WorkloadAttestor "unix" {
+        plugin_data {}
+    }
+}
diff --git a/spiffe/spire/entrypoint.sh b/spiffe/spire/entrypoint.sh
new file mode 100755
index 00000000..35a82ac3
--- /dev/null
+++ b/spiffe/spire/entrypoint.sh
@@ -0,0 +1,58 @@
+#!/bin/sh
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Bootstraps a complete (single node) SPIRE deployment for the example:
+#  1. starts the SPIRE server
+#  2. registers the workloads, mapping the Unix user id of each Camel 
application to its SPIFFE ID
+#  3. generates a join token and starts the SPIRE agent with it, which then 
serves the SPIFFE Workload API
+set -e
+
+SPIRE_BIN=/opt/spire/bin
+SERVER_SOCKET=/tmp/spire-server/private/api.sock
+TRUST_DOMAIN=example.org
+AGENT_ID="spiffe://${TRUST_DOMAIN}/spire-agent"
+
+# 1. the server issues all the identities of the trust domain
+"${SPIRE_BIN}/spire-server" run -config /opt/spire/conf/server.conf &
+
+echo "Waiting for the SPIRE server to be ready..."
+until "${SPIRE_BIN}/spire-server" healthcheck -socketPath "${SERVER_SOCKET}" > 
/dev/null 2>&1; do
+    sleep 1
+done
+
+# the agent verifies the server with the CA bundle of the trust domain
+"${SPIRE_BIN}/spire-server" bundle show -socketPath "${SERVER_SOCKET}" > 
/opt/spire/data/bootstrap.crt
+
+# 2. a registration entry tells SPIRE which workload (selectors) gets which 
identity (SPIFFE ID).
+# The Camel applications of this example each run as a different Unix user, so 
the uid is the selector.
+register() {
+    echo "Registering spiffe://${TRUST_DOMAIN}/$1 for the workload running 
with uid $2"
+    "${SPIRE_BIN}/spire-server" entry create -socketPath "${SERVER_SOCKET}" \
+        -parentID "${AGENT_ID}" \
+        -spiffeID "spiffe://${TRUST_DOMAIN}/$1" \
+        -selector "unix:uid:$2"
+}
+register frontend 1001
+register backend 1002
+register auditor 1003
+register inventory 1004
+
+# 3. the agent attests to the server with a one-time join token (the -spiffeID 
option also gives the agent
+# the alias AGENT_ID, which the entries above use as parent ID)
+TOKEN=$("${SPIRE_BIN}/spire-server" token generate -socketPath 
"${SERVER_SOCKET}" -spiffeID "${AGENT_ID}" \
+    | awk '/^Token:/ { print $2 }')
+
+exec "${SPIRE_BIN}/spire-agent" run -config /opt/spire/conf/agent.conf 
-joinToken "${TOKEN}"
diff --git a/spiffe/spire/server.conf b/spiffe/spire/server.conf
new file mode 100644
index 00000000..065e0d56
--- /dev/null
+++ b/spiffe/spire/server.conf
@@ -0,0 +1,55 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# SPIRE server configuration, see 
https://github.com/spiffe/spire/blob/main/doc/spire_server.md
+server {
+    bind_address = "127.0.0.1"
+    bind_port = "8081"
+    socket_path = "/tmp/spire-server/private/api.sock"
+    trust_domain = "example.org"
+    data_dir = "/opt/spire/data/server"
+    log_level = "INFO"
+
+    # short lifetimes, so that the example shows the rotation of the SVIDs 
while you watch the logs
+    ca_ttl = "24h"
+    default_x509_svid_ttl = "10m"
+    default_jwt_svid_ttl = "5m"
+
+    ca_subject {
+        country = ["US"]
+        organization = ["Apache Camel"]
+        common_name = "example.org"
+    }
+}
+
+plugins {
+    DataStore "sql" {
+        plugin_data {
+            database_type = "sqlite3"
+            connection_string = "/opt/spire/data/server/datastore.sqlite3"
+        }
+    }
+
+    KeyManager "disk" {
+        plugin_data {
+            keys_path = "/opt/spire/data/server/keys.json"
+        }
+    }
+
+    # the agent joins with a one-time token generated by entrypoint.sh
+    NodeAttestor "join_token" {
+        plugin_data {}
+    }
+}
diff --git a/spiffe/src/main/docker/Dockerfile 
b/spiffe/src/main/docker/Dockerfile
new file mode 100644
index 00000000..9e3f9585
--- /dev/null
+++ b/spiffe/src/main/docker/Dockerfile
@@ -0,0 +1,30 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+FROM eclipse-temurin:21-jre
+
+# each application of the example runs as its own Unix user: the SPIRE agent 
maps the uid to a SPIFFE ID
+RUN useradd --uid 1001 --user-group --no-create-home frontend \
+    && useradd --uid 1002 --user-group --no-create-home backend \
+    && useradd --uid 1003 --user-group --no-create-home auditor \
+    && useradd --uid 1004 --user-group --no-create-home inventory
+
+COPY target/lib /deployments/lib
+COPY target/camel-example-spiffe-*.jar /deployments/lib/
+
+WORKDIR /deployments
+ENTRYPOINT ["java", "-cp", "/deployments/lib/*"]
+# the main class to run: compose.yaml overrides it for the frontend and the 
auditor
+CMD ["org.apache.camel.example.spiffe.backend.BackendApplication"]
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/IdentityRoutes.java 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/IdentityRoutes.java
new file mode 100644
index 00000000..c419d764
--- /dev/null
+++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/IdentityRoutes.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe;
+
+import org.apache.camel.LoggingLevel;
+import org.apache.camel.builder.RouteBuilder;
+
+/**
+ * Fetches the X.509-SVID of this workload from the SPIFFE Workload API at 
regular intervals and logs a summary of it.
+ * <p>
+ * Both applications of this example run this very same route, yet each of 
them is issued a different identity: the
+ * SPIRE agent attests the process that connects to the Workload API (in this 
example by its Unix user id) and looks up
+ * the registration entry that matches it. Identity comes from the platform, 
not from the code or its configuration.
+ * <p>
+ * The X.509-SVID is short-lived and the SPIRE agent rotates it before it 
expires, so the serial number and the validity
+ * period in the log change over time without the application doing anything 
about it.
+ */
+public class IdentityRoutes extends RouteBuilder {
+
+    @Override
+    public void configure() {
+        // the SPIRE agent may not have attested this workload yet: log the 
problem and try again on the next tick
+        onException(Exception.class)
+                .handled(true)
+                .log(LoggingLevel.WARN, "Could not fetch the X.509-SVID: 
${exception.message}");
+
+        from("timer:identity?period={{identity.period}}").routeId("identity")
+                // fetchX509Svid is also the default operation of the 
component. The message body becomes an
+                // io.spiffe.svid.x509svid.X509Svid and the SPIFFE ID is set 
as the CamelSpiffeSpiffeId header
+                .to("spiffe:identity?operation=fetchX509Svid")
+                // the X509Svid also carries the private key of the workload, 
so never log the body as-is
+                .bean(X509SvidSummary.class, "describe")
+                .log("${body}");
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/X509SvidSummary.java 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/X509SvidSummary.java
new file mode 100644
index 00000000..12544622
--- /dev/null
+++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/X509SvidSummary.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe;
+
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import io.spiffe.svid.x509svid.X509Svid;
+
+/**
+ * Turns an {@link X509Svid} into a human-readable summary of its leaf 
certificate. Only the certificate is described:
+ * the private key that comes with the SVID is never printed.
+ */
+public class X509SvidSummary {
+
+    /**
+     * The GeneralName type of a uniformResourceIdentifier subject alternative 
name, which is where a SPIFFE ID is
+     * encoded in an X.509-SVID.
+     */
+    private static final int URI_NAME = 6;
+
+    public String describe(X509Svid svid) throws CertificateParsingException {
+        X509Certificate leaf = svid.getLeaf();
+        return String.format("""
+                X.509-SVID of %s
+                    serial number : %s
+                    subject       : %s
+                    issuer        : %s
+                    valid from    : %s
+                    valid until   : %s
+                    URI SANs      : %s
+                    chain length  : %d certificate(s)""",
+                svid.getSpiffeId(),
+                leaf.getSerialNumber().toString(16),
+                leaf.getSubjectX500Principal(),
+                leaf.getIssuerX500Principal(),
+                leaf.getNotBefore().toInstant(),
+                leaf.getNotAfter().toInstant(),
+                uriSubjectAlternativeNames(leaf),
+                svid.getChain().size());
+    }
+
+    private static List<String> uriSubjectAlternativeNames(X509Certificate 
certificate)
+            throws CertificateParsingException {
+        List<String> uris = new ArrayList<>();
+        Collection<List<?>> names = certificate.getSubjectAlternativeNames();
+        if (names != null) {
+            for (List<?> name : names) {
+                if (Integer.valueOf(URI_NAME).equals(name.get(0))) {
+                    uris.add(String.valueOf(name.get(1)));
+                }
+            }
+        }
+        return uris;
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendApplication.java
 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendApplication.java
new file mode 100644
index 00000000..4d93e597
--- /dev/null
+++ 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendApplication.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.backend;
+
+import org.apache.camel.example.spiffe.IdentityRoutes;
+import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy;
+import org.apache.camel.main.Main;
+
+/**
+ * Boots the backend: an HTTP API for orders that only serves callers with a 
valid JWT-SVID, and that calls the
+ * inventory service with its own identity.
+ */
+public final class BackendApplication {
+
+    private BackendApplication() {
+    }
+
+    public static void main(String[] args) throws Exception {
+        Main main = new Main();
+        // the embedded HTTP server
+        main.configure().httpServer().withEnabled(true).withPort(8080);
+        // the workload identity policy of this service, and the routes that 
use it
+        WorkloadIdentityPolicy policy = new WorkloadIdentityPolicy("backend");
+        main.configure().addRoutesBuilder(policy);
+        main.configure().addRoutesBuilder(new 
BackendRoutes(policy.getAuditTrail()));
+        main.configure().addRoutesBuilder(IdentityRoutes.class);
+        // now keep the application running until the JVM is terminated (ctrl 
+ c or sigterm)
+        main.run(args);
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendRoutes.java
 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendRoutes.java
new file mode 100644
index 00000000..8890b1f3
--- /dev/null
+++ 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/BackendRoutes.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.backend;
+
+import java.util.Map;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.LoggingLevel;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+import org.apache.camel.example.spiffe.policy.AuditTrail;
+import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy;
+
+/**
+ * The backend exposes two HTTP routes, both guarded by the {@link 
WorkloadIdentityPolicy}: the orders, which are
+ * completed with the stock levels of the inventory service (the second hop), 
and the audit trail of the policy.
+ */
+public class BackendRoutes extends RouteBuilder {
+
+    private final AuditTrail auditTrail;
+
+    public BackendRoutes(AuditTrail auditTrail) {
+        this.auditTrail = auditTrail;
+    }
+
+    @Override
+    public void configure() {
+        // the inventory could not be reached, or refused our identity: the 
orders cannot be served
+        onException(Exception.class)
+                .handled(true)
+                .log(LoggingLevel.ERROR, "Could not get the stock levels from 
the inventory: ${exception.message}")
+                .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(502))
+                .setHeader(Exchange.CONTENT_TYPE, constant("text/plain"))
+                .setBody(simple("502 Bad Gateway: ${exception.message}"))
+                .removeHeaders("CamelSpiffe*");
+
+        
from("platform-http:/api/orders?httpMethodRestrict=GET").routeId("orders")
+                .routeConfigurationId(WorkloadIdentityPolicy.ID)
+                // the policy has authenticated and authorized the caller by 
the time the route starts
+                .bean(OrderService.class, "listOrders")
+                .setProperty("orders", body())
+                .setProperty("onBehalfOf", header(SpiffeConstants.SPIFFE_ID))
+                .to("direct:stockLevels")
+                .bean(OrderService.class, "withStock")
+                .marshal().json()
+                .removeHeaders("CamelSpiffe*");
+
+        // the second hop: ask the inventory service for the stock levels, 
with our own identity. The JWT-SVID is
+        // minted for the inventory (its audience), and the caller we are 
serving travels along in a header for the
+        // audit trail of the inventory, which trusts it because we are 
authenticated and allowed to call it
+        from("direct:stockLevels").routeId("stock-levels")
+                
.to("spiffe:backend?operation=fetchJwtSvid&audience={{inventory.audience}}")
+                .setHeader("Authorization", simple("Bearer ${body}"))
+                .setHeader("X-On-Behalf-Of", exchangeProperty("onBehalfOf"))
+                .setBody(simple("${null}"))
+                // the headers of the request we are serving must not shape 
the request we are making
+                .removeHeaders("CamelHttp*")
+                .removeHeaders("CamelSpiffe*")
+                
.to("http://{{inventory.host}}:{{inventory.port}}/api/stock?httpMethod=GET";)
+                // our token is not for the caller to see
+                .removeHeader("Authorization")
+                .removeHeader("X-On-Behalf-Of")
+                .unmarshal().json(Map.class);
+
+        
from("platform-http:/api/audit?httpMethodRestrict=GET").routeId("audit")
+                .routeConfigurationId(WorkloadIdentityPolicy.ID)
+                .bean(auditTrail, "report")
+                .marshal().json()
+                .removeHeaders("CamelSpiffe*");
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/OrderService.java
 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/OrderService.java
new file mode 100644
index 00000000..4d2851d2
--- /dev/null
+++ 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/backend/OrderService.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.backend;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.ExchangeProperty;
+import org.apache.camel.Header;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+
+/**
+ * Stands in for the real business logic: returns a few orders, together with 
the identity of the caller they are
+ * served to, and completes them with the stock levels of the inventory.
+ */
+public class OrderService {
+
+    public Map<String, Object> listOrders(@Header(SpiffeConstants.SPIFFE_ID) 
String caller) {
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("caller", caller);
+        response.put("orders", List.of(
+                order(1001, "Camel in Action, 2nd edition", 2),
+                order(1002, "Enterprise Integration Patterns", 1),
+                order(1003, "Zero Trust Networks", 3)));
+        return response;
+    }
+
+    /**
+     * Marks each order as in stock or not, from the stock levels answered by 
the inventory (item to quantity).
+     */
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> withStock(
+            @ExchangeProperty("orders") Map<String, Object> response, 
Map<String, Object> stockLevels) {
+        for (Map<String, Object> order : (List<Map<String, Object>>) 
response.get("orders")) {
+            int available = ((Number) 
stockLevels.getOrDefault(order.get("item"), 0)).intValue();
+            order.put("inStock", available >= (Integer) order.get("quantity"));
+        }
+        return response;
+    }
+
+    private static Map<String, Object> order(int id, String item, int 
quantity) {
+        Map<String, Object> order = new LinkedHashMap<>();
+        order.put("id", id);
+        order.put("item", item);
+        order.put("quantity", quantity);
+        return order;
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/frontend/FrontendApplication.java
 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/frontend/FrontendApplication.java
new file mode 100644
index 00000000..be157cbc
--- /dev/null
+++ 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/frontend/FrontendApplication.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.frontend;
+
+import org.apache.camel.example.spiffe.IdentityRoutes;
+import org.apache.camel.main.Main;
+
+/**
+ * Boots the frontend: a client that authenticates to the backend with the 
JWT-SVIDs it gets from the SPIFFE Workload
+ * API.
+ * <p>
+ * The auditor of this example runs this very same class. It ends up with 
another SPIFFE ID because it runs as another
+ * Unix user, which the SPIRE agent maps to another registration entry.
+ */
+public final class FrontendApplication {
+
+    private FrontendApplication() {
+    }
+
+    public static void main(String[] args) throws Exception {
+        Main main = new Main();
+        main.configure().addRoutesBuilder(FrontendRoutes.class);
+        main.configure().addRoutesBuilder(IdentityRoutes.class);
+        // now keep the application running until the JVM is terminated (ctrl 
+ c or sigterm)
+        main.run(args);
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/frontend/FrontendRoutes.java
 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/frontend/FrontendRoutes.java
new file mode 100644
index 00000000..104fc44b
--- /dev/null
+++ 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/frontend/FrontendRoutes.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.frontend;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.LoggingLevel;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+
+/**
+ * The frontend proves who it is to the backend with a JWT-SVID minted by the 
SPIFFE Workload API for the backend (the
+ * audience of the token). There is no shared secret, password or API key 
anywhere: the SPIRE agent attests the process
+ * and issues short-lived tokens for the identity that was registered for it.
+ */
+public class FrontendRoutes extends RouteBuilder {
+
+    @Override
+    public void configure() {
+        // the backend may still be starting, or the SPIRE agent may not have 
attested this workload yet:
+        // log the problem and try again on the next timer tick
+        onException(Exception.class)
+                .handled(true)
+                .log(LoggingLevel.WARN, "Could not call the backend: 
${exception.message}");
+
+        // every few seconds: read the orders
+        from("timer:orders?period={{frontend.period}}").routeId("orders")
+                .setHeader(Exchange.HTTP_PATH, constant("/api/orders"))
+                .to("direct:callBackend");
+
+        // less often: read the audit trail of the backend
+        
from("timer:audit?period={{frontend.audit.period}}&delay={{frontend.audit.period}}").routeId("audit")
+                .setHeader(Exchange.HTTP_PATH, constant("/api/audit"))
+                .to("direct:callBackend");
+
+        // now and then: ask for a token minted for some other service (the 
CamelSpiffeAudience header overrides the
+        // audience of the endpoint) and present that one instead. The backend 
must reject it (HTTP 401): the token
+        // is genuine, but its audience is not the backend
+        
from("timer:wrongAudience?period={{frontend.wrongAudience.period}}&delay={{frontend.wrongAudience.period}}")
+                .routeId("wrong-audience")
+                .setHeader(Exchange.HTTP_PATH, constant("/api/orders"))
+                .setHeader(SpiffeConstants.AUDIENCE, 
simple("{{frontend.wrongAudience}}"))
+                .log("Asking for a JWT-SVID with the wrong audience, the 
backend should reject it")
+                .to("direct:callBackend");
+
+        // get a JWT-SVID for the backend and present it as a bearer token, on 
the path set by the caller
+        from("direct:callBackend").routeId("call-backend")
+                
.to("spiffe:frontend?operation=fetchJwtSvid&audience={{backend.audience}}")
+                .log("Fetched a JWT-SVID for ${header.CamelSpiffeSpiffeId} 
(valid until ${header.CamelSpiffeExpiry})")
+                .setHeader("Authorization", simple("Bearer ${body}"))
+                // the token travels in the header only, and the CamelSpiffe* 
headers are of no use to the backend
+                .setBody(simple("${null}"))
+                .removeHeaders("CamelSpiffe*")
+                
.to("http://{{backend.host}}:{{backend.port}}?httpMethod=GET&throwExceptionOnFailure=false";)
+                .log("GET ${header.CamelHttpPath} answered HTTP 
${header.CamelHttpResponseCode}: ${body}");
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/InventoryApplication.java
 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/InventoryApplication.java
new file mode 100644
index 00000000..26c887f2
--- /dev/null
+++ 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/InventoryApplication.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.inventory;
+
+import org.apache.camel.example.spiffe.IdentityRoutes;
+import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy;
+import org.apache.camel.main.Main;
+
+/**
+ * Boots the inventory: the second hop of the example, an HTTP API that only 
the backend may call.
+ */
+public final class InventoryApplication {
+
+    private InventoryApplication() {
+    }
+
+    public static void main(String[] args) throws Exception {
+        Main main = new Main();
+        // the embedded HTTP server
+        main.configure().httpServer().withEnabled(true).withPort(8080);
+        // the very same policy as the backend, configured for this service
+        main.configure().addRoutesBuilder(new 
WorkloadIdentityPolicy("inventory"));
+        main.configure().addRoutesBuilder(InventoryRoutes.class);
+        main.configure().addRoutesBuilder(IdentityRoutes.class);
+        // now keep the application running until the JVM is terminated (ctrl 
+ c or sigterm)
+        main.run(args);
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/InventoryRoutes.java
 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/InventoryRoutes.java
new file mode 100644
index 00000000..b7b9320a
--- /dev/null
+++ 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/InventoryRoutes.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.inventory;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy;
+
+/**
+ * The inventory answers the stock levels to the backend, and to nobody else.
+ */
+public class InventoryRoutes extends RouteBuilder {
+
+    @Override
+    public void configure() {
+        
from("platform-http:/api/stock?httpMethodRestrict=GET").routeId("stock")
+                .routeConfigurationId(WorkloadIdentityPolicy.ID)
+                // the policy has checked that the caller is the backend. The 
backend says on whose behalf it asks,
+                // which can be trusted because the backend itself is 
authenticated and allowed to call this route
+                .log("Serving the stock levels to 
${header.CamelSpiffeSpiffeId} on behalf of ${header.X-On-Behalf-Of}")
+                .bean(StockService.class, "levels")
+                .marshal().json()
+                .removeHeaders("CamelSpiffe*");
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/StockService.java
 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/StockService.java
new file mode 100644
index 00000000..7776d1b2
--- /dev/null
+++ 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/inventory/StockService.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.inventory;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Stands in for the real business logic: the stock level of each item.
+ */
+public class StockService {
+
+    public Map<String, Integer> levels() {
+        Map<String, Integer> levels = new LinkedHashMap<>();
+        levels.put("Camel in Action, 2nd edition", 12);
+        levels.put("Enterprise Integration Patterns", 0);
+        levels.put("Zero Trust Networks", 5);
+        return levels;
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AllowList.java 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AllowList.java
new file mode 100644
index 00000000..e1c5cd95
--- /dev/null
+++ b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AllowList.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.policy;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+import org.apache.camel.CamelContext;
+
+/**
+ * Decides which callers may use which route. Authentication is left to 
SPIFFE, so this is all the authorization logic
+ * the services need: an allow-list of SPIFFE IDs per route, read from the 
configuration as
+ * {@code <service>.allow.<route id>}. A route without an allow-list accepts 
nobody.
+ */
+public class AllowList {
+
+    private final CamelContext camelContext;
+    private final String prefix;
+
+    public AllowList(CamelContext camelContext, String service) {
+        this.camelContext = camelContext;
+        this.prefix = service + ".allow.";
+    }
+
+    public boolean isAllowed(String routeId, String spiffeId) {
+        if (routeId == null || spiffeId == null) {
+            return false;
+        }
+        Optional<String> allowedCallers = 
camelContext.getPropertiesComponent().resolveProperty(prefix + routeId);
+        return allowedCallers.stream()
+                .flatMap(callers -> Arrays.stream(callers.split(",")))
+                .map(String::trim)
+                .anyMatch(spiffeId::equals);
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AuditTrail.java 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AuditTrail.java
new file mode 100644
index 00000000..0e76e3a6
--- /dev/null
+++ 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/AuditTrail.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.policy;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Remembers the last decisions taken by the {@link WorkloadIdentityPolicy} of 
a service: who called which route, and
+ * whether the request was allowed, denied (authenticated, but not on the 
allow-list) or rejected (not authenticated).
+ */
+public class AuditTrail {
+
+    private static final int CAPACITY = 20;
+
+    private final String service;
+    private final Deque<Map<String, Object>> decisions = new ArrayDeque<>();
+
+    public AuditTrail(String service) {
+        this.service = service;
+    }
+
+    public synchronized void record(String route, String caller, String 
outcome, String detail) {
+        Map<String, Object> decision = new LinkedHashMap<>();
+        decision.put("time", 
Instant.now().truncatedTo(ChronoUnit.SECONDS).toString());
+        decision.put("route", route);
+        decision.put("caller", caller == null ? "anonymous" : caller);
+        decision.put("outcome", outcome);
+        if (detail != null) {
+            decision.put("detail", detail);
+        }
+        if (decisions.size() == CAPACITY) {
+            decisions.removeFirst();
+        }
+        decisions.addLast(decision);
+    }
+
+    public synchronized Map<String, Object> report() {
+        Map<String, Object> report = new LinkedHashMap<>();
+        report.put("service", service);
+        report.put("decisions", List.copyOf(decisions));
+        return report;
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/BearerToken.java 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/BearerToken.java
new file mode 100644
index 00000000..67d5de2e
--- /dev/null
+++ 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/BearerToken.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.policy;
+
+import org.apache.camel.Header;
+
+/**
+ * Extracts the bearer token from the {@code Authorization} header of an HTTP 
request.
+ */
+public class BearerToken {
+
+    private static final String SCHEME = "Bearer ";
+
+    public String extract(@Header("Authorization") String authorization) {
+        if (authorization == null || !authorization.regionMatches(true, 0, 
SCHEME, 0, SCHEME.length())) {
+            throw new IllegalArgumentException("no bearer token in the 
Authorization header");
+        }
+        String token = authorization.substring(SCHEME.length()).trim();
+        if (token.isEmpty()) {
+            throw new IllegalArgumentException("empty bearer token in the 
Authorization header");
+        }
+        return token;
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/RejectionReason.java
 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/RejectionReason.java
new file mode 100644
index 00000000..1c89c789
--- /dev/null
+++ 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/RejectionReason.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.policy;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.ExchangeProperty;
+
+/**
+ * Explains why a request was rejected, from the exception that stopped it. 
When the SPIFFE Workload API refuses a
+ * token, the java-spiffe library reports a generic "Error validating JWT 
SVID" and keeps the actual reason (expired,
+ * wrong audience, unknown key, ...) in the cause, so that one is added to the 
explanation.
+ */
+public class RejectionReason {
+
+    public String of(@ExchangeProperty(Exchange.EXCEPTION_CAUGHT) Exception 
exception) {
+        StringBuilder reason = new StringBuilder(exception.getMessage());
+        Throwable cause = exception.getCause();
+        if (cause != null && cause.getMessage() != null) {
+            reason.append(": ").append(cause.getMessage());
+        }
+        return reason.toString();
+    }
+}
diff --git 
a/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/WorkloadIdentityPolicy.java
 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/WorkloadIdentityPolicy.java
new file mode 100644
index 00000000..a951a705
--- /dev/null
+++ 
b/spiffe/src/main/java/org/apache/camel/example/spiffe/policy/WorkloadIdentityPolicy.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.policy;
+
+import io.spiffe.exception.JwtSvidException;
+import org.apache.camel.Exchange;
+import org.apache.camel.LoggingLevel;
+import org.apache.camel.builder.RouteConfigurationBuilder;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+import org.apache.camel.model.RouteConfigurationDefinition;
+
+/**
+ * The workload identity policy shared by the services that expose an HTTP API 
(the backend and the inventory). It is
+ * a route configuration: every route that opts in with {@code 
routeConfigurationId(WorkloadIdentityPolicy.ID)} gets
+ * these checks before its own steps run, so the routes contain business logic 
only.
+ * <ul>
+ * <li>Authentication: the request must carry a JWT-SVID as bearer token, 
minted for this service (the audience). The
+ * SPIFFE Workload API checks the signature, the expiry and the audience; a 
failure is answered with HTTP 401.</li>
+ * <li>Authorization: the SPIFFE ID of the caller must be on the allow-list of 
the route (see {@link AllowList}), or
+ * the request is answered with HTTP 403.</li>
+ * <li>Audit: every decision is recorded in the {@link AuditTrail} of the 
service.</li>
+ * </ul>
+ */
+public class WorkloadIdentityPolicy extends RouteConfigurationBuilder {
+
+    /** The id with which the HTTP routes opt in to this policy. */
+    public static final String ID = "workload-identity";
+
+    private final String service;
+    private final AuditTrail auditTrail;
+
+    /**
+     * @param service the name of the service, used to look up its audience 
({@code <service>.audience}) and its
+     *                allow-lists ({@code <service>.allow.<route id>}) in the 
configuration
+     */
+    public WorkloadIdentityPolicy(String service) {
+        this.service = service;
+        this.auditTrail = new AuditTrail(service);
+    }
+
+    public AuditTrail getAuditTrail() {
+        return auditTrail;
+    }
+
+    @Override
+    public void configuration() {
+        AllowList allowList = new AllowList(getContext(), service);
+        RouteConfigurationDefinition policy = routeConfiguration(ID);
+
+        // whatever goes wrong while checking the token (missing, expired, 
wrong audience, bad signature, ...)
+        // means that the caller is not authenticated
+        policy.onException(JwtSvidException.class, 
IllegalArgumentException.class)
+                .handled(true)
+                .setBody(method(RejectionReason.class, "of"))
+                .bean(auditTrail, "record(${routeId}, null, 'rejected', 
${body})")
+                .log(LoggingLevel.WARN, "Rejected request to ${routeId}: 
${body}")
+                .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(401))
+                .setHeader(Exchange.CONTENT_TYPE, constant("text/plain"))
+                .setBody(simple("401 Unauthorized: ${body}"))
+                .removeHeaders("CamelSpiffe*");
+
+        // runs before the first step of every route that uses this policy
+        policy.interceptFrom()
+                // 1. authentication: the token must be a JWT-SVID minted for 
this service (the audience), signed by
+                //    the trust domain and still valid. The SPIRE agent checks 
all of that: the message body becomes
+                //    the validated io.spiffe.svid.jwtsvid.JwtSvid and the 
SPIFFE ID of the caller is set as header
+                .setHeader(SpiffeConstants.TOKEN).method(BearerToken.class, 
"extract")
+                .removeHeader("Authorization")
+                .to("spiffe:" + service + 
"?operation=validateJwtSvid&audience={{" + service + ".audience}}")
+                // 2. authorization: the caller must be on the allow-list of 
the route
+                .choice()
+                    .when(method(allowList, "isAllowed(${routeId}, 
${header.CamelSpiffeSpiffeId})"))
+                        .bean(auditTrail, "record(${routeId}, 
${header.CamelSpiffeSpiffeId}, 'allowed', null)")
+                        .log("Authenticated caller 
${header.CamelSpiffeSpiffeId}, allowed to call ${routeId}")
+                    .otherwise()
+                        .bean(auditTrail, "record(${routeId}, 
${header.CamelSpiffeSpiffeId}, 'denied', null)")
+                        .log(LoggingLevel.WARN,
+                                "Authenticated caller 
${header.CamelSpiffeSpiffeId} is not allowed to call ${routeId}")
+                        .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(403))
+                        .setHeader(Exchange.CONTENT_TYPE, 
constant("text/plain"))
+                        .setBody(simple("403 Forbidden: 
${header.CamelSpiffeSpiffeId} is not allowed to call ${routeId}"))
+                        .removeHeaders("CamelSpiffe*")
+                        // the route itself does not run
+                        .stop()
+                .end();
+    }
+}
diff --git a/spiffe/src/main/resources/application.properties 
b/spiffe/src/main/resources/application.properties
new file mode 100644
index 00000000..776b1c07
--- /dev/null
+++ b/spiffe/src/main/resources/application.properties
@@ -0,0 +1,52 @@
+## ---------------------------------------------------------------------------
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements.  See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License.  You may obtain a copy of the License at
+##
+##      http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+## ---------------------------------------------------------------------------
+
+# here you can configure options on camel main
+# https://camel.apache.org/components/next/others/main.html
+# compose.yaml overrides the name of each application with the CAMEL_MAIN_NAME 
environment variable
+camel.main.name = camel-spiffe
+
+# The SPIFFE component connects to the SPIFFE Workload API of the local SPIRE 
agent. When the option below is not
+# set, the address is taken from the SPIFFE_ENDPOINT_SOCKET environment 
variable (which is what compose.yaml does).
+#camel.component.spiffe.spiffe-socket-path = 
unix:///run/spire/sockets/agent.sock
+
+# The SPIFFE IDs of the services that expose an HTTP API. A caller asks for a 
JWT-SVID with the service as
+# audience, and the service only accepts tokens that were minted for it
+backend.audience = spiffe://example.org/backend
+inventory.audience = spiffe://example.org/inventory
+
+# Who may call what: the SPIFFE IDs (comma separated) allowed on each route, 
as <service>.allow.<route id>.
+# A route without an allow-list accepts nobody
+backend.allow.orders = spiffe://example.org/frontend
+backend.allow.audit = spiffe://example.org/auditor
+inventory.allow.stock = spiffe://example.org/backend
+
+# Where the services are found
+backend.host = backend
+backend.port = 8080
+inventory.host = inventory
+inventory.port = 8080
+
+# How often the frontend reads the orders and the audit trail
+frontend.period = 10s
+frontend.audit.period = 30s
+# How often the frontend presents a token minted for another service, to show 
that the backend rejects it
+frontend.wrongAudience.period = 45s
+frontend.wrongAudience = spiffe://example.org/some-other-service
+
+# How often the applications log their X.509-SVID
+identity.period = 60s
diff --git a/spiffe/src/main/resources/log4j2.properties 
b/spiffe/src/main/resources/log4j2.properties
new file mode 100644
index 00000000..fd23b980
--- /dev/null
+++ b/spiffe/src/main/resources/log4j2.properties
@@ -0,0 +1,23 @@
+## ---------------------------------------------------------------------------
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements.  See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License.  You may obtain a copy of the License at
+##
+##      http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+## ---------------------------------------------------------------------------
+
+appender.out.type = Console
+appender.out.name = out
+appender.out.layout.type = PatternLayout
+appender.out.layout.pattern = %d{HH:mm:ss.SSS} [%-20.20t] %-28.28c{1} %-5p %m%n
+rootLogger.level = INFO
+rootLogger.appenderRef.out.ref = out
diff --git 
a/spiffe/src/test/java/org/apache/camel/example/spiffe/IdentityRoutesTest.java 
b/spiffe/src/test/java/org/apache/camel/example/spiffe/IdentityRoutesTest.java
new file mode 100644
index 00000000..74d6cad6
--- /dev/null
+++ 
b/spiffe/src/test/java/org/apache/camel/example/spiffe/IdentityRoutesTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe;
+
+import java.io.InputStream;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.List;
+
+import io.spiffe.exception.X509ContextException;
+import io.spiffe.spiffeid.SpiffeId;
+import io.spiffe.svid.x509svid.X509Svid;
+import io.spiffe.workloadapi.WorkloadApiClient;
+import io.spiffe.workloadapi.X509Context;
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+import org.apache.camel.main.MainConfigurationProperties;
+import org.apache.camel.test.main.junit6.CamelMainTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests the identity route against a fake SPIFFE Workload API that hands out 
a self-signed certificate with the SPIFFE
+ * ID of the frontend as URI subject alternative name (see 
src/test/resources/frontend-svid.pem).
+ */
+class IdentityRoutesTest extends CamelMainTestSupport {
+
+    @BindToRegistry
+    private final WorkloadApiClient workloadApiClient = 
mock(WorkloadApiClient.class);
+
+    @Override
+    protected void configure(MainConfigurationProperties configuration) {
+        configuration.addRoutesBuilder(IdentityRoutes.class);
+    }
+
+    @Override
+    public void setupResources() throws Exception {
+        // trigger the route by hand instead of waiting for the timer
+        camelContextConfiguration().replaceRouteFromWith("identity", 
"direct:identity");
+        super.setupResources();
+    }
+
+    @Test
+    void describesTheX509SvidWithoutRevealingTheKey() throws Exception {
+        X509Certificate leaf = loadCertificate("/frontend-svid.pem");
+        X509Svid svid = mock(X509Svid.class);
+        
when(svid.getSpiffeId()).thenReturn(SpiffeId.parse("spiffe://example.org/frontend"));
+        when(svid.getLeaf()).thenReturn(leaf);
+        when(svid.getChain()).thenReturn(List.of(leaf));
+        X509Context x509Context = mock(X509Context.class);
+        when(x509Context.getDefaultSvid()).thenReturn(svid);
+        when(workloadApiClient.fetchX509Context()).thenReturn(x509Context);
+
+        Exchange out = template.request("direct:identity", exchange -> {
+        });
+
+        String summary = out.getMessage().getBody(String.class);
+        assertTrue(summary.startsWith("X.509-SVID of 
spiffe://example.org/frontend"), summary);
+        assertTrue(summary.contains("subject       : CN=frontend, O=Apache 
Camel, C=US"), summary);
+        assertTrue(summary.contains("URI SANs      : 
[spiffe://example.org/frontend]"), summary);
+        assertTrue(summary.contains("chain length  : 1 certificate(s)"), 
summary);
+        assertEquals("spiffe://example.org/frontend", 
out.getMessage().getHeader(SpiffeConstants.SPIFFE_ID));
+        verify(svid, never()).getPrivateKey();
+    }
+
+    @Test
+    void failuresAreLoggedAndDoNotStopTheRoute() throws Exception {
+        when(workloadApiClient.fetchX509Context()).thenThrow(new 
X509ContextException("no identity issued"));
+
+        Exchange out = template.request("direct:identity", exchange -> {
+        });
+
+        assertFalse(out.isFailed(), "the failure is handled by the route");
+    }
+
+    private static X509Certificate loadCertificate(String resource) throws 
Exception {
+        try (InputStream in = 
IdentityRoutesTest.class.getResourceAsStream(resource)) {
+            return (X509Certificate) 
CertificateFactory.getInstance("X.509").generateCertificate(in);
+        }
+    }
+}
diff --git 
a/spiffe/src/test/java/org/apache/camel/example/spiffe/backend/BackendRoutesTest.java
 
b/spiffe/src/test/java/org/apache/camel/example/spiffe/backend/BackendRoutesTest.java
new file mode 100644
index 00000000..3a869839
--- /dev/null
+++ 
b/spiffe/src/test/java/org/apache/camel/example/spiffe/backend/BackendRoutesTest.java
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.backend;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Date;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.ConcurrentHashMap;
+
+import io.spiffe.exception.JwtSvidException;
+import io.spiffe.spiffeid.SpiffeId;
+import io.spiffe.svid.jwtsvid.JwtSvid;
+import io.spiffe.workloadapi.WorkloadApiClient;
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy;
+import org.apache.camel.main.MainConfigurationProperties;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.junit6.CamelContextConfiguration;
+import org.apache.camel.test.main.junit6.CamelMainTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.camel.util.PropertiesHelper.asProperties;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests the backend over HTTP, on the embedded server of Camel Main, against 
a fake SPIFFE Workload API and a stub of
+ * the inventory service. The spiffe component autowires the single {@link 
WorkloadApiClient} it finds in the
+ * registry, so the routes and the policy under test are exactly the ones used 
at runtime: only the SPIRE agent is
+ * replaced.
+ */
+class BackendRoutesTest extends CamelMainTestSupport {
+
+    private static final String BACKEND = "spiffe://example.org/backend";
+    private static final String INVENTORY = "spiffe://example.org/inventory";
+    private static final String FRONTEND = "spiffe://example.org/frontend";
+    private static final String AUDITOR = "spiffe://example.org/auditor";
+    private static final String STOCK_LEVELS
+            = "{\"Camel in Action, 2nd edition\":12,\"Enterprise Integration 
Patterns\":0,\"Zero Trust Networks\":5}";
+
+    // static, because configureContext() runs in the constructor of 
CamelTestSupport, before the instance
+    // fields are initialized
+    private static final int PORT = AvailablePortFinder.getNextAvailable();
+    private static final HttpClient HTTP = HttpClient.newHttpClient();
+    private final WorkloadIdentityPolicy policy = new 
WorkloadIdentityPolicy("backend");
+    /** The headers of the last request received by the stub inventory. */
+    private final Map<String, Object> inventoryRequestHeaders = new 
ConcurrentHashMap<>();
+
+    @BindToRegistry
+    private final WorkloadApiClient workloadApiClient = 
mock(WorkloadApiClient.class);
+
+    @Override
+    protected void configure(MainConfigurationProperties configuration) {
+        configuration.httpServer().withEnabled(true).withPort(PORT);
+        configuration.addRoutesBuilder(policy);
+        configuration.addRoutesBuilder(new 
BackendRoutes(policy.getAuditTrail()));
+        // a stub of the inventory service, on the same embedded server
+        configuration.addRoutesBuilder(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("platform-http:/api/stock").routeId("stub-inventory")
+                        .process(exchange -> {
+                            inventoryRequestHeaders.clear();
+                            exchange.getMessage().getHeaders().forEach((name, 
value) -> {
+                                if (value != null) {
+                                    inventoryRequestHeaders.put(name, value);
+                                }
+                            });
+                        })
+                        .setHeader(Exchange.CONTENT_TYPE, 
constant("application/json"))
+                        .setBody(constant(STOCK_LEVELS));
+            }
+        });
+    }
+
+    @Override
+    public void configureContext(CamelContextConfiguration 
camelContextConfiguration) {
+        super.configureContext(camelContextConfiguration);
+        Properties overrides = asProperties("inventory.host", "localhost", 
"inventory.port", Integer.toString(PORT));
+        
camelContextConfiguration.withUseOverridePropertiesWithPropertiesComponent(overrides);
+    }
+
+    @Test
+    void frontendGetsTheOrdersWithTheStockLevels() throws Exception {
+        JwtSvid frontend = jwtSvid(FRONTEND, null);
+        when(workloadApiClient.validateJwtSvid("frontend-token", 
BACKEND)).thenReturn(frontend);
+        JwtSvid backend = jwtSvid(BACKEND, "backend-token");
+        when(workloadApiClient.fetchJwtSvid(INVENTORY)).thenReturn(backend);
+
+        HttpResponse<String> response = get("/api/orders", "Bearer 
frontend-token");
+
+        assertEquals(200, response.statusCode());
+        String body = response.body();
+        
assertTrue(body.contains("\"caller\":\"spiffe://example.org/frontend\""), body);
+        assertTrue(body.contains("\"item\":\"Camel in Action, 2nd 
edition\",\"quantity\":2,\"inStock\":true"), body);
+        assertTrue(body.contains("\"item\":\"Enterprise Integration 
Patterns\",\"quantity\":1,\"inStock\":false"), body);
+
+        // the second hop was made with the identity of the backend, on behalf 
of the frontend
+        assertEquals("Bearer backend-token", 
inventoryRequestHeaders.get("Authorization"));
+        assertEquals(FRONTEND, inventoryRequestHeaders.get("X-On-Behalf-Of"));
+
+        // and nothing of it leaks back to the caller
+        for (String header : new String[] {
+                "Authorization", "X-On-Behalf-Of", SpiffeConstants.TOKEN, 
SpiffeConstants.SPIFFE_ID }) {
+            assertTrue(response.headers().firstValue(header).isEmpty(), header 
+ " must not be in the response");
+        }
+    }
+
+    @Test
+    void auditorMayNotReadTheOrders() throws Exception {
+        JwtSvid auditor = jwtSvid(AUDITOR, null);
+        when(workloadApiClient.validateJwtSvid("auditor-token", 
BACKEND)).thenReturn(auditor);
+
+        HttpResponse<String> response = get("/api/orders", "Bearer 
auditor-token");
+
+        assertEquals(403, response.statusCode());
+        assertEquals("403 Forbidden: spiffe://example.org/auditor is not 
allowed to call orders",
+                response.body());
+    }
+
+    @Test
+    void auditorReadsTheAuditTrail() throws Exception {
+        JwtSvid auditor = jwtSvid(AUDITOR, null);
+        when(workloadApiClient.validateJwtSvid("auditor-token", 
BACKEND)).thenReturn(auditor);
+
+        // a denied call first, so that there is something to audit
+        get("/api/orders", "Bearer auditor-token");
+        HttpResponse<String> response = get("/api/audit", "Bearer 
auditor-token");
+
+        assertEquals(200, response.statusCode());
+        String body = response.body();
+        
assertTrue(body.startsWith("{\"service\":\"backend\",\"decisions\":["), body);
+        
assertTrue(body.contains("\"route\":\"orders\",\"caller\":\"spiffe://example.org/auditor\",\"outcome\":\"denied\""),
+                body);
+        
assertTrue(body.contains("\"route\":\"audit\",\"caller\":\"spiffe://example.org/auditor\",\"outcome\":\"allowed\""),
+                body);
+    }
+
+    @Test
+    void frontendMayNotReadTheAuditTrail() throws Exception {
+        JwtSvid frontend = jwtSvid(FRONTEND, null);
+        when(workloadApiClient.validateJwtSvid("frontend-token", 
BACKEND)).thenReturn(frontend);
+
+        HttpResponse<String> response = get("/api/audit", "Bearer 
frontend-token");
+
+        assertEquals(403, response.statusCode());
+    }
+
+    @Test
+    void invalidTokenIsUnauthorized() throws Exception {
+        // this is how the java-spiffe library reports a token that the 
Workload API refused
+        when(workloadApiClient.validateJwtSvid("token-for-another-service", 
BACKEND))
+                .thenThrow(new JwtSvidException("Error validating JWT SVID",
+                        new IllegalStateException("expected audience in 
[spiffe://example.org/backend]")));
+
+        HttpResponse<String> response = get("/api/orders", "Bearer 
token-for-another-service");
+
+        assertEquals(401, response.statusCode());
+        assertEquals("401 Unauthorized: Error validating JWT SVID: expected 
audience in [spiffe://example.org/backend]",
+                response.body());
+    }
+
+    @Test
+    void missingTokenIsUnauthorized() throws Exception {
+        HttpResponse<String> response = get("/api/orders", null);
+
+        assertEquals(401, response.statusCode());
+        assertEquals("401 Unauthorized: no bearer token in the Authorization 
header",
+                response.body());
+    }
+
+    @Test
+    void unreachableInventoryIsABadGateway() throws Exception {
+        JwtSvid frontend = jwtSvid(FRONTEND, null);
+        when(workloadApiClient.validateJwtSvid("frontend-token", 
BACKEND)).thenReturn(frontend);
+        when(workloadApiClient.fetchJwtSvid(INVENTORY)).thenThrow(new 
JwtSvidException("no identity issued"));
+
+        HttpResponse<String> response = get("/api/orders", "Bearer 
frontend-token");
+
+        assertEquals(502, response.statusCode());
+        assertEquals("502 Bad Gateway: no identity issued", response.body());
+    }
+
+    private static HttpResponse<String> get(String path, String authorization) 
throws Exception {
+        HttpRequest.Builder request = 
HttpRequest.newBuilder(URI.create("http://localhost:"; + PORT + path)).GET();
+        if (authorization != null) {
+            request.header("Authorization", authorization);
+        }
+        return HTTP.send(request.build(), 
HttpResponse.BodyHandlers.ofString());
+    }
+
+    private static JwtSvid jwtSvid(String spiffeId, String token) {
+        JwtSvid svid = mock(JwtSvid.class);
+        when(svid.getSpiffeId()).thenReturn(SpiffeId.parse(spiffeId));
+        when(svid.getToken()).thenReturn(token);
+        when(svid.getExpiry()).thenReturn(new Date(System.currentTimeMillis() 
+ 300_000));
+        return svid;
+    }
+}
diff --git 
a/spiffe/src/test/java/org/apache/camel/example/spiffe/frontend/FrontendRoutesTest.java
 
b/spiffe/src/test/java/org/apache/camel/example/spiffe/frontend/FrontendRoutesTest.java
new file mode 100644
index 00000000..d3174250
--- /dev/null
+++ 
b/spiffe/src/test/java/org/apache/camel/example/spiffe/frontend/FrontendRoutesTest.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.frontend;
+
+import java.util.Date;
+
+import io.spiffe.exception.JwtSvidException;
+import io.spiffe.spiffeid.SpiffeId;
+import io.spiffe.svid.jwtsvid.JwtSvid;
+import io.spiffe.workloadapi.WorkloadApiClient;
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.AdviceWith;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.spiffe.SpiffeConstants;
+import org.apache.camel.main.MainConfigurationProperties;
+import org.apache.camel.test.main.junit6.CamelMainTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests the frontend routes against a fake SPIFFE Workload API (a mocked 
{@link WorkloadApiClient} that the spiffe
+ * component autowires from the registry) and a mock endpoint in place of the 
backend.
+ */
+class FrontendRoutesTest extends CamelMainTestSupport {
+
+    private static final String BACKEND = "spiffe://example.org/backend";
+
+    @BindToRegistry
+    private final WorkloadApiClient workloadApiClient = 
mock(WorkloadApiClient.class);
+
+    @Override
+    protected void configure(MainConfigurationProperties configuration) {
+        configuration.addRoutesBuilder(FrontendRoutes.class);
+    }
+
+    @Override
+    public void setupResources() throws Exception {
+        // trigger the routes by hand instead of waiting for the timers
+        camelContextConfiguration().replaceRouteFromWith("orders", 
"direct:orders");
+        camelContextConfiguration().replaceRouteFromWith("audit", 
"direct:audit");
+        camelContextConfiguration().replaceRouteFromWith("wrong-audience", 
"direct:wrongAudience");
+        super.setupResources();
+    }
+
+    @Override
+    public boolean isUseAdviceWith() {
+        // the routes are advised before the context is started, to swap the 
real backend for a mock endpoint
+        return true;
+    }
+
+    @Test
+    void readsTheOrdersWithAJwtSvidAsBearerToken() throws Exception {
+        MockEndpoint backend = mockTheBackend();
+        JwtSvid svid = jwtSvid("frontend-token");
+        when(workloadApiClient.fetchJwtSvid(BACKEND)).thenReturn(svid);
+
+        backend.expectedMessageCount(1);
+        backend.expectedHeaderReceived(Exchange.HTTP_PATH, "/api/orders");
+        backend.expectedHeaderReceived("Authorization", "Bearer 
frontend-token");
+        backend.message(0).body().isNull();
+        backend.message(0).header(SpiffeConstants.SPIFFE_ID).isNull();
+        backend.message(0).header(SpiffeConstants.EXPIRY).isNull();
+
+        template.sendBody("direct:orders", null);
+
+        backend.assertIsSatisfied();
+    }
+
+    @Test
+    void readsTheAuditTrail() throws Exception {
+        MockEndpoint backend = mockTheBackend();
+        JwtSvid svid = jwtSvid("frontend-token");
+        when(workloadApiClient.fetchJwtSvid(BACKEND)).thenReturn(svid);
+
+        backend.expectedMessageCount(1);
+        backend.expectedHeaderReceived(Exchange.HTTP_PATH, "/api/audit");
+        backend.expectedHeaderReceived("Authorization", "Bearer 
frontend-token");
+
+        template.sendBody("direct:audit", null);
+
+        backend.assertIsSatisfied();
+    }
+
+    @Test
+    void asksForATokenWithTheWrongAudienceOnPurpose() throws Exception {
+        MockEndpoint backend = mockTheBackend();
+        JwtSvid svid = jwtSvid("token-for-another-service");
+        
when(workloadApiClient.fetchJwtSvid("spiffe://example.org/some-other-service")).thenReturn(svid);
+
+        backend.expectedMessageCount(1);
+        backend.expectedHeaderReceived("Authorization", "Bearer 
token-for-another-service");
+        backend.message(0).header(SpiffeConstants.AUDIENCE).isNull();
+
+        template.sendBody("direct:wrongAudience", null);
+
+        backend.assertIsSatisfied();
+    }
+
+    @Test
+    void failuresAreLoggedAndDoNotStopTheRoute() throws Exception {
+        MockEndpoint backend = mockTheBackend();
+        when(workloadApiClient.fetchJwtSvid(BACKEND)).thenThrow(new 
JwtSvidException("no identity issued"));
+
+        backend.expectedMessageCount(0);
+
+        // the failure is handled by the route, so it does not propagate to 
the caller
+        template.sendBody("direct:orders", null);
+
+        backend.assertIsSatisfied();
+    }
+
+    private MockEndpoint mockTheBackend() throws Exception {
+        AdviceWith.adviceWith(context, "call-backend",
+                advice -> 
advice.weaveByToUri("http:*").replace().to("mock:backend"));
+        context.start();
+        return getMockEndpoint("mock:backend");
+    }
+
+    private static JwtSvid jwtSvid(String token) {
+        JwtSvid svid = mock(JwtSvid.class);
+        when(svid.getToken()).thenReturn(token);
+        
when(svid.getSpiffeId()).thenReturn(SpiffeId.parse("spiffe://example.org/frontend"));
+        when(svid.getExpiry()).thenReturn(new Date(System.currentTimeMillis() 
+ 300_000));
+        return svid;
+    }
+}
diff --git 
a/spiffe/src/test/java/org/apache/camel/example/spiffe/inventory/InventoryRoutesTest.java
 
b/spiffe/src/test/java/org/apache/camel/example/spiffe/inventory/InventoryRoutesTest.java
new file mode 100644
index 00000000..9eaa4280
--- /dev/null
+++ 
b/spiffe/src/test/java/org/apache/camel/example/spiffe/inventory/InventoryRoutesTest.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.example.spiffe.inventory;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Date;
+
+import io.spiffe.spiffeid.SpiffeId;
+import io.spiffe.svid.jwtsvid.JwtSvid;
+import io.spiffe.workloadapi.WorkloadApiClient;
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.example.spiffe.policy.WorkloadIdentityPolicy;
+import org.apache.camel.main.MainConfigurationProperties;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.main.junit6.CamelMainTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests the inventory over HTTP, on the embedded server of Camel Main, 
against a fake SPIFFE Workload API.
+ */
+class InventoryRoutesTest extends CamelMainTestSupport {
+
+    private static final String INVENTORY = "spiffe://example.org/inventory";
+
+    // static, because configureContext() runs in the constructor of 
CamelTestSupport, before the instance
+    // fields are initialized
+    private static final int PORT = AvailablePortFinder.getNextAvailable();
+    private static final HttpClient HTTP = HttpClient.newHttpClient();
+
+    @BindToRegistry
+    private final WorkloadApiClient workloadApiClient = 
mock(WorkloadApiClient.class);
+
+    @Override
+    protected void configure(MainConfigurationProperties configuration) {
+        configuration.httpServer().withEnabled(true).withPort(PORT);
+        configuration.addRoutesBuilder(new 
WorkloadIdentityPolicy("inventory"));
+        configuration.addRoutesBuilder(InventoryRoutes.class);
+    }
+
+    @Test
+    void backendGetsTheStockLevels() throws Exception {
+        JwtSvid backend = jwtSvid("spiffe://example.org/backend");
+        when(workloadApiClient.validateJwtSvid("backend-token", 
INVENTORY)).thenReturn(backend);
+
+        HttpResponse<String> response = get("Bearer backend-token", 
"spiffe://example.org/frontend");
+
+        assertEquals(200, response.statusCode());
+        assertEquals("{\"Camel in Action, 2nd edition\":12,\"Enterprise 
Integration Patterns\":0,\"Zero Trust Networks\":5}",
+                response.body());
+    }
+
+    @Test
+    void frontendMayNotAskTheInventoryDirectly() throws Exception {
+        JwtSvid frontend = jwtSvid("spiffe://example.org/frontend");
+        when(workloadApiClient.validateJwtSvid("frontend-token", 
INVENTORY)).thenReturn(frontend);
+
+        HttpResponse<String> response = get("Bearer frontend-token", null);
+
+        assertEquals(403, response.statusCode());
+        assertEquals("403 Forbidden: spiffe://example.org/frontend is not 
allowed to call stock",
+                response.body());
+    }
+
+    @Test
+    void missingTokenIsUnauthorized() throws Exception {
+        HttpResponse<String> response = get(null, null);
+
+        assertEquals(401, response.statusCode());
+    }
+
+    private static HttpResponse<String> get(String authorization, String 
onBehalfOf) throws Exception {
+        HttpRequest.Builder request = 
HttpRequest.newBuilder(URI.create("http://localhost:"; + PORT + 
"/api/stock")).GET();
+        if (authorization != null) {
+            request.header("Authorization", authorization);
+        }
+        if (onBehalfOf != null) {
+            request.header("X-On-Behalf-Of", onBehalfOf);
+        }
+        return HTTP.send(request.build(), 
HttpResponse.BodyHandlers.ofString());
+    }
+
+    private static JwtSvid jwtSvid(String spiffeId) {
+        JwtSvid svid = mock(JwtSvid.class);
+        when(svid.getSpiffeId()).thenReturn(SpiffeId.parse(spiffeId));
+        when(svid.getExpiry()).thenReturn(new Date(System.currentTimeMillis() 
+ 300_000));
+        return svid;
+    }
+}
diff --git a/spiffe/src/test/resources/frontend-svid.pem 
b/spiffe/src/test/resources/frontend-svid.pem
new file mode 100644
index 00000000..63f00190
--- /dev/null
+++ b/spiffe/src/test/resources/frontend-svid.pem
@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIIB8DCCAZWgAwIBAgIUUEZwu9rNbF2gLwWYkEzaS6w2Uw0wCgYIKoZIzj0EAwIw
+NzELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEFwYWNoZSBDYW1lbDERMA8GA1UEAwwI
+ZnJvbnRlbmQwIBcNMjYwOTAzMTAxNDI5WhgPMjEyNjA4MTAxMDE0MjlaMDcxCzAJ
+BgNVBAYTAlVTMRUwEwYDVQQKDAxBcGFjaGUgQ2FtZWwxETAPBgNVBAMMCGZyb250
+ZW5kMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE+8BNBh69yOHdjyBxMc7yR0Xj
+YtkN+EM8rKJg0b4zJzVyeFaFv7fzzWdb1abTt3bxrQq0GzVCW3Jpp9fmYyeulKN9
+MHswHQYDVR0OBBYEFOdgdZQ/sgnlVO1ZJfOxn+YP5w3bMB8GA1UdIwQYMBaAFOdg
+dZQ/sgnlVO1ZJfOxn+YP5w3bMA8GA1UdEwEB/wQFMAMBAf8wKAYDVR0RBCEwH4Yd
+c3BpZmZlOi8vZXhhbXBsZS5vcmcvZnJvbnRlbmQwCgYIKoZIzj0EAwIDSQAwRgIh
+ALEaw2LsIBOjDCRb7z0bSqe8eui/yC7gRsK3Xbf0XvbyAiEA6aJRod4tvQ8lzlfv
+KM21qw/vVWfvj7fFtE44mcBOcuA=
+-----END CERTIFICATE-----

Reply via email to