1
0

Add the service-to-service module

This commit is contained in:
2026-08-28 10:02:47 +05:30
parent cad813e1ae
commit 0fceb2cd4e
37 changed files with 2566 additions and 4 deletions

View File

@@ -11,6 +11,7 @@ by that module's `scripts/run-all.sh`, never typed by hand.
| [`method-security/`](method-security/README.md) | [Method Security in Spring Security 7: `@PreAuthorize`, `@PostAuthorize` and the Proxy Traps](https://ankurm.com/spring-security-7-method-security-proxy-traps/) | What the method-security annotations do, the full SpEL surface, and the cases where the check silently does not run |
| [`filter-chain/`](filter-chain/README.md) | [The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/) | Every filter in the default chain and its order number, where a custom filter actually lands, and how to read the TRACE log |
| [`cors-csrf/`](cors-csrf/README.md) | [CORS, CSRF and SameSite in Spring Boot 4](https://ankurm.com/spring-boot-4-cors-csrf-samesite/) | Why MVC-layer CORS does not fix a security-layer preflight rejection, what `csrf.spa()` assigns, and the cookie a browser silently refuses to store |
| [`service-to-service/`](service-to-service/README.md) | [Securing Spring Boot Microservices: Token Relay, Service-to-Service JWT and mTLS](https://ankurm.com/spring-boot-microservices-token-relay-mtls/) | Whose identity arrives at the last service under relay, client credentials and token exchange — and what a resource server does not check by default |
They are related more closely than they look. `filter-chain` is about how an `Authentication`
gets into `SecurityContextHolder` in the first place and in what order; `context-propagation` is
@@ -20,6 +21,12 @@ thread it ends up on. An `@Async` method carrying `@PreAuthorize` fails with
the third — and a custom authentication filter that never populated the context in the first
place fails the same way, for reasons that belong to the first.
`service-to-service` is the same question one process further out: `cors-csrf` and
`context-propagation` ask whether an identity survives a thread or a browser boundary, and this
one asks whether it survives an HTTP boundary — and what the service on the far side bothers to
verify about it. Its `/edge/relay-async` endpoint fails for exactly the reason
`context-propagation` documents.
`cors-csrf` is where those order numbers stop being trivia. `CorsFilter` at 1000 and `CsrfFilter`
at 1100 both sit far above `AuthorizationFilter` at 4200, and almost every confusing symptom in
that module is a consequence of one of those three positions — including a 403 that arrives as a
@@ -33,14 +40,16 @@ manages. Versions were taken from `maven-metadata.xml` on Maven Central rather t
release announcements.
`context-propagation` additionally needs `--enable-preview`, because `StructuredTaskScope` is
still a preview API on JDK 25. `method-security` does not. `filter-chain` and `cors-csrf` are
real servlet applications: they inherit `spring-boot-starter-parent` and run on Tomcat, because
the things they demonstrate only exist inside a servlet container.
still a preview API on JDK 25. `method-security` does not. `filter-chain`, `cors-csrf` and
`service-to-service` are real servlet applications: they inherit `spring-boot-starter-parent`
and run on Tomcat, because the things they demonstrate only exist inside a servlet container.
`service-to-service` runs five of them at once, and is the only module that also pulls in
Spring Cloud — a separate release train, built against Boot 4.0.8 rather than 4.1.1.
## Running a module
```bash
cd cors-csrf # or context-propagation, method-security, filter-chain
cd cors-csrf # or context-propagation, method-security, filter-chain, service-to-service
./scripts/run-all.sh # every demo plus the test suite, regenerating docs/output/
mvn test # just the assertions
```

View File

@@ -0,0 +1,103 @@
# `service-to-service` — token relay, client credentials, exchange and mTLS
Companion project for
[**Securing Spring Boot Microservices: Token Relay, Service-to-Service JWT and mTLS**](https://ankurm.com/spring-boot-microservices-token-relay-mtls/)
on ankurm.com.
Four real processes and a real Spring Authorization Server, so that questions like "whose
identity arrives at the last service?" and "what does that service actually check?" have
transcripts for answers. Everything under [`docs/output/`](docs/output/) was produced by
`scripts/run-all.sh`.
## Versions
| | Version | Notes |
|---|---|---|
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
| Spring Boot | 4.1.1 | inherited as parent |
| Spring Framework | 7.0.9 | |
| Spring Security | 7.1.1 | resource server, OAuth2 client, authorization server |
| Spring Cloud | 2025.1.3 (gateway 5.0.3) | **built against Boot 4.0.8** — see [docs/01](docs/01-the-four-processes.md) |
| Tomcat | 11.0.24 | |
Versions were read from `repo1.maven.org/.../maven-metadata.xml`.
## Quickstart
```bash
./scripts/run.sh # all four processes, in order
TOKEN=$(./scripts/user-token.sh) # a real authorization_code + PKCE flow, in curl
./scripts/claims.sh "$TOKEN"
curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/relay
curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/client-credentials
curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/exchange
STRICT=true ./scripts/run.sh # issuer + audience + RFC 9068 validation
./scripts/certs.sh && ./scripts/run-all.sh
mvn test # the 8 validator assertions
./scripts/stop.sh
```
The user is `alice` / `password`.
## Processes
| Process | Port | Main class | Role |
|---|---|---|---|
| authserver | 9000 | [`AuthServerApplication`](src/main/java/com/ankurm/s2s/authserver/AuthServerApplication.java) | Spring Authorization Server. Mints every token used here |
| gateway | 8080 | [`GatewayApplication`](src/main/java/com/ankurm/s2s/gateway/GatewayApplication.java) | Spring Cloud Gateway Server MVC, with and without `TokenRelay` |
| edge | 8081 | [`EdgeApplication`](src/main/java/com/ankurm/s2s/edge/EdgeApplication.java) | Resource server **and** OAuth2 client |
| downstream | 8082 | [`DownstreamApplication`](src/main/java/com/ankurm/s2s/downstream/DownstreamApplication.java) | Resource server. Reports who it thinks is calling |
| mtls | 8443 | [`MtlsApplication`](src/main/java/com/ankurm/s2s/mtls/MtlsApplication.java) | Certificate authentication instead of tokens |
## Endpoints
| Endpoint | Strategy |
|---|---|
| `GET /edge/naive` | No token forwarded — the control |
| `GET /edge/relay` | The incoming bearer token, unchanged |
| `GET /edge/client-credentials` | The edge service's own identity |
| `GET /edge/exchange` | RFC 8693 token exchange |
| `GET /edge/relay-async` | Relay from another thread — the `ThreadLocal` trap |
| `GET /orders` | Downstream. Echoes `sub`, `aud`, `scope`, `client_id`, `cnf` |
| `GET /mtls/whoami` | The verified certificate identity |
| `GET /mtls/trusted-header` | An identity taken from a header, verified by nothing |
## Switches
| Switch | Effect |
|---|---|
| `STRICT=true ./scripts/run.sh` | Authorization server emits RFC 9068 tokens; downstream validates issuer, audience and the required-claim set |
| `RS_LOG_LEVEL` / `CLIENT_LOG_LEVEL` / `AS_LOG_LEVEL` / `GATEWAY_LOG_LEVEL` | `DEBUG` on the corresponding package |
## Documentation
| Chapter | |
|---|---|
| [01](docs/01-the-four-processes.md) | The four processes, a browser flow in curl, and four things that cost time |
| [02](docs/02-three-ways-to-get-a-token.md) | Relay, client credentials, token exchange — and the thread that loses the token |
| [03](docs/03-restclient-interceptors.md) | `OAuth2ClientHttpRequestInterceptor` and which `OAuth2AuthorizedClientManager` |
| [04](docs/04-what-is-not-validated.md) | **What a resource server does not validate by default** |
| [05](docs/05-the-gateway.md) | What `TokenRelay` actually relays |
| [06](docs/06-mtls.md) | Mesh mTLS versus in-application mTLS |
| [07](docs/07-choosing.md) | Choosing, and whether you need any of it |
## Captured output
| File | |
|---|---|
| [01-user-token.txt](docs/output/01-user-token.txt) | A complete authorization_code + PKCE flow, in curl |
| [02-five-strategies.txt](docs/output/02-five-strategies.txt) | Five propagation strategies, one request |
| [03-audience-ignored.txt](docs/output/03-audience-ignored.txt) | A token for another service, accepted with HTTP 200 |
| [04-gateway-token-relay.txt](docs/output/04-gateway-token-relay.txt) | The same route with and without `TokenRelay` |
| [05-strict-validation.txt](docs/output/05-strict-validation.txt) | Strict validation, and what turning it on breaks |
| [06-mtls.txt](docs/output/06-mtls.txt) | Two certificates with the same subject and different issuers |
| [07-tests.txt](docs/output/07-tests.txt) | `mvn test` |
## Related modules
- [`filter-chain/`](../filter-chain/README.md) — where `BearerTokenAuthenticationFilter` sits
- [`context-propagation/`](../context-propagation/README.md) — why `/edge/relay-async` returns 401
- [`cors-csrf/`](../cors-csrf/README.md) — the browser-facing half of the same problem
- [`method-security/`](../method-security/README.md) — turning a scope into an authorization decision

View File

@@ -0,0 +1,75 @@
# 1. The four processes
*Next: [2. Three ways to get a token for the next hop](02-three-ways-to-get-a-token.md)*
Everything in this module runs against four real processes, because the questions it answers
(“whose identity arrives?”, “what does the receiving service check?”) only have answers when
there is a second process to arrive at.
| Process | Port | What it is |
|---|---|---|
| `authserver` | 9000 | A real Spring Authorization Server. Mints every token used anywhere here |
| `gateway` | 8080 | Spring Cloud Gateway Server MVC, proxying to `edge` |
| `edge` | 8081 | Resource server **and** OAuth2 client. The middle hop |
| `downstream` | 8082 | Resource server. Reports who it thinks is calling |
| `mtls` | 8443 | Separate. Certificate authentication instead of tokens — chapter 6 |
`./scripts/run.sh` starts the first four in order and waits for each.
## Getting a user token without a browser
The "browser flow" is four HTTP requests and a cookie jar, and
[`scripts/user-token.sh`](../scripts/user-token.sh) does all of it with `curl`:
1. `GET /oauth2/authorize?…` unauthenticated → the request is saved, 302 to `/login`
2. `GET /login`, scrape the `_csrf` hidden field
3. `POST /login` with the credentials and that token → 302 back to the saved request
4. `GET` the authorize URL again, now authenticated → 302 to the redirect URI with `?code=`
5. `POST /oauth2/token` with the code and the PKCE `code_verifier`
[`docs/output/01-user-token.txt`](output/01-user-token.txt) is the token that comes out. Doing
this once by hand is the fastest way to understand what an OIDC library is doing on your behalf.
## Four things that cost time while building this
**`issuer-uri` creates a startup-ordering dependency.** Configuring an OAuth2 *client* with
`spring.security.oauth2.client.provider.<id>.issuer-uri` makes `ClientRegistrations` fetch
`/.well-known/openid-configuration` during context refresh. If the authorization server is not
up, the client will not start:
```
ResourceAccessException: I/O error on GET request for
"http://127.0.0.1:9000/.well-known/openid-configuration": Connection refused
```
Nothing in that message says "start order". Naming `token-uri` and `jwk-set-uri` explicitly
removes the coupling, which is why `edge.yml` does.
**`OAuth2AuthorizationServerConfiguration` no longer exists.** Every pre-7.0 Authorization
Server tutorial opens with
`OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)`. That class, and the whole
`org.springframework.security.oauth2.server.authorization.config.annotation.web.*` package tree,
is **absent** from `spring-security-oauth2-authorization-server` 7.1.1. The configurer moved into
`spring-security-config` at
`org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization`,
and there is now a DSL method:
```java
http.oauth2AuthorizationServer(Customizer.withDefaults())
```
It is a compile error, not a deprecation warning.
**A public client is asked for consent.** `ClientAuthenticationMethod.NONE` plus
`authorization_code` produced a `Consent required` page mid-flow, which stops any scripted
redemption dead. `ClientSettings.builder().requireAuthorizationConsent(false)` is explicit in
`AuthServerApplication` for that reason.
**Spring Cloud is a different release train.** `spring-cloud-dependencies` 2025.1.3 —
gateway 5.0.3 — declares `<spring-boot.version>4.0.8</spring-boot.version>` in
`spring-cloud-build`. This module runs it under Boot **4.1.1** and it works, but that is a
combination nobody tested, and it is the reason a Boot upgrade can be blocked by a gateway.
Check `spring-cloud-build`'s POM before assuming the trains are aligned.
---
*Next: [2. Three ways to get a token for the next hop](02-three-ways-to-get-a-token.md)*

View File

@@ -0,0 +1,100 @@
# 2. Three ways to get a token for the next hop
*Prev: [1. The four processes](01-the-four-processes.md) &middot; Next: [3. RestClient interceptors](03-restclient-interceptors.md)*
The edge service has a valid token in its hands and needs to call `downstream`. There are three
answers, and [`docs/output/02-five-strategies.txt`](output/02-five-strategies.txt) runs all of
them against the same request so the difference is a diff.
| Endpoint | `sub` downstream sees | `scope` downstream sees |
|---|---|---|
| `/edge/naive` | — (401) | — |
| `/edge/relay` | `alice` | `[orders.write, orders.read]` |
| `/edge/client-credentials` | `edge-service` | `[orders.read]` |
| `/edge/exchange` | `alice` | `[orders.read]` |
| `/edge/relay-async` | — (401) | — |
## Relay
Forward the token you were given, unchanged. Ten lines, no dependency on the OAuth2 client
machinery:
```java
builder.requestInterceptor((request, body, execution) -> {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken token) {
request.getHeaders().setBearerAuth(token.getToken().getTokenValue());
}
return execution.execute(request, body);
})
```
Downstream sees `alice`, which is what you want in an audit log. It also sees
`orders.write`, which downstream had no business receiving: the token was minted for the
request the user made at the edge, and every service it is relayed to gets the union of
everything the user consented to. One compromised service holds a credential good against all
of them for the rest of its lifetime.
Relay is right for a hop inside one trust boundary. It is wrong the moment the next hop is
operated by someone who should not hold the user's full token.
## Client credentials
The service authenticates as itself:
```yaml
spring.security.oauth2.client.registration.edge-service:
authorization-grant-type: client_credentials
scope: orders.read
```
Downstream now sees `sub: edge-service` and exactly `orders.read`. The scope problem is solved.
The user is gone — `downstream`'s log says a service called it, and answering "who ordered
this?" requires correlating two logs by a request id you hope somebody propagated.
## Token exchange (RFC 8693)
Trade the user's token for one scoped to the next hop, keeping the user:
```yaml
authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange
scope: orders.read
```
`sub: alice`, `scope: [orders.read]`. Both properties, from one grant type that has existed
since 2020 and that almost nobody reaches for.
The catch is configuration, not concept. `OAuth2AuthorizedClientProviderBuilder`'s defaults do
**not** include token exchange, so you have to add the provider yourself:
```java
OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.refreshToken()
.provider(new TokenExchangeOAuth2AuthorizedClientProvider())
.build();
```
Leave it out and the manager returns `null` rather than raising anything, and the interceptor
sends the request with no `Authorization` header at all. A 401 that looks like a downstream
problem and is a wiring problem.
## The trap: relay from another thread
`/edge/relay-async` does exactly what `/edge/relay` does, on a virtual thread from an executor.
It returns 401.
`SecurityContextHolder` is a `ThreadLocal`. The interceptor reads it; the task runs on a thread
that never had a `SecurityContext` written to it; the `instanceof` fails; the header is never
set. Nothing throws. This is the same mechanism as
[Spring Security Context Propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/),
and the cure is the same: `DelegatingSecurityContextExecutorService`, or
`ContextPropagatingTaskDecorator`, or capture the token value on the request thread and pass it
as a parameter.
Any relay built on `SecurityContextHolder` inherits every one of those failure modes. A relay
built on an explicit token parameter inherits none of them, at the cost of a parameter on every
method between the controller and the client.
---
*Prev: [1. The four processes](01-the-four-processes.md) &middot; Next: [3. RestClient interceptors](03-restclient-interceptors.md)*

View File

@@ -0,0 +1,77 @@
# 3. `RestClient` interceptors
*Prev: [2. Three ways to get a token](02-three-ways-to-get-a-token.md) &middot; Next: [4. What a resource server does not validate](04-what-is-not-validated.md)*
`OAuth2ClientHttpRequestInterceptor`, in
`org.springframework.security.oauth2.client.web.client`, is the framework's answer for a
`RestClient` that needs a token. Its whole public surface:
```java
public OAuth2ClientHttpRequestInterceptor(OAuth2AuthorizedClientManager manager);
public void setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler handler);
public void setClientRegistrationIdResolver(ClientRegistrationIdResolver resolver);
public void setPrincipalResolver(PrincipalResolver resolver);
```
Wiring it takes one line, and choosing the registration per call takes one more:
```java
RestClient client = builder
.baseUrl("http://127.0.0.1:8082")
.requestInterceptor(new OAuth2ClientHttpRequestInterceptor(authorizedClientManager))
.build();
client.get().uri("/orders")
.attributes(clientRegistrationId("edge-service")) // static import
.retrieve().body(Map.class);
```
`clientRegistrationId(..)` is a static method on `RequestAttributeClientRegistrationIdResolver`.
Without it, the default resolver finds nothing and the request goes out unauthenticated.
## Which `OAuth2AuthorizedClientManager`
This is the choice that decides whether the thing works off a request thread.
| Manager | Storage | Needs a request? |
|---|---|---|
| `DefaultOAuth2AuthorizedClientManager` | `OAuth2AuthorizedClientRepository` (session) | **Yes** |
| `AuthorizedClientServiceOAuth2AuthorizedClientManager` | `OAuth2AuthorizedClientService` | No |
For service-to-service calls there is no end user whose authorization is being stored per
session, so the second one is right — and it is the one that keeps working from a scheduled
task, a message listener or an `@Async` method.
Getting this wrong produces `ClientAuthorizationRequiredException` or a silent `null` in a
context that has no `HttpServletRequest`, which reads like an OAuth problem and is a bean
problem.
## What it caches, and what it does not
The manager stores the authorized client (access token and, if issued, refresh token) in the
`OAuth2AuthorizedClientService` and reuses it until it is within the clock skew of expiry.
So a `client_credentials` registration does **not** hit the token endpoint per request. What it
does do is re-request on expiry, synchronously, inside whichever call happens to be first —
worth knowing when a latency percentile spikes on a period that matches your token lifetime.
## The hand-rolled relay, and why it is still reasonable
The relay interceptor in
[`DownstreamClients`](../src/main/java/com/ankurm/s2s/edge/DownstreamClients.java) does not use
any of the above:
```java
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken token) {
request.getHeaders().setBearerAuth(token.getToken().getTokenValue());
}
```
That is not a worse version of `OAuth2ClientHttpRequestInterceptor`; it is a different thing.
The interceptor **obtains** a token under a client registration. This **forwards** the token
already in hand. There is no client registration for "the caller's token", and there should not
be. Just do not confuse the two: the hand-rolled one carries the `ThreadLocal` dependency from
chapter 2, and the framework one does not.
---
*Prev: [2. Three ways to get a token](02-three-ways-to-get-a-token.md) &middot; Next: [4. What a resource server does not validate](04-what-is-not-validated.md)*

View File

@@ -0,0 +1,125 @@
# 4. What a resource server does not validate
*Prev: [3. RestClient interceptors](03-restclient-interceptors.md) &middot; Next: [5. The gateway](05-the-gateway.md)*
This is the most important chapter in the module.
## The demonstration
`reporting-service` is a client registered on the same authorization server, with nothing to do
with the downstream service. Its access tokens carry `aud: reporting-api`.
Send one to the downstream service, whose audience is `downstream-api`:
```
HTTP/1.1 200
{ "sub": "reporting-service", "aud": ["reporting-api"], "scope": "[orders.read]", ... }
```
Two hundred. Full transcript:
[`docs/output/03-audience-ignored.txt`](output/03-audience-ignored.txt).
## Why
`JwtValidators.createDefault()` is a `DelegatingOAuth2TokenValidator` over three validators,
read back by reflection in `ValidatorContractTests.defaultDelegates`:
- `JwtTypeValidator`
- `JwtTimestampValidator`
- `X509CertificateThumbprintValidator`
Structure, expiry, and certificate binding. **No issuer. No audience. No scope.** Setting
`spring.security.oauth2.resourceserver.jwt.issuer-uri` gets you a `JwtIssuerValidator` on top of
that, so the issuer is covered. Nothing anywhere adds an audience check unless you write it.
The practical consequence: in an estate where several services trust one authorization server —
which is the normal shape — **any token from that issuer is accepted by every service in it**.
A token a partner integration obtained for the reporting API is a valid credential for the
payments API. Scope may or may not save you; `scope: orders.read` did not, above.
## The fix, and its price
```java
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
new JwtAudienceValidator("downstream-api")));
```
`JwtAudienceValidator` is a public class in 7.1. Two lines, and the demonstration above turns
into `The aud claim is not valid`.
The heavier option is the RFC 9068 profile:
```java
JwtValidators.createAtJwtValidator()
.issuer("http://127.0.0.1:9000")
.audience("downstream-api")
.build();
```
That requires `typ`, `exp`, `sub`, `iat`, `jti`, `iss`, `aud` **and `client_id`** to all be
present. It is stricter and it is also where two surprises live.
## Surprise 1: Spring Authorization Server does not emit an RFC 9068 token
Out of the box its access tokens carry `typ: JWT` in the header and no `client_id` claim. Point
`createAtJwtValidator()` at one and every token is rejected. Both are one line in an
`OAuth2TokenCustomizer`:
```java
context.getJwsHeader().type("at+jwt");
context.getClaims().claim("client_id", clientId);
```
## Surprise 2: making the token compliant breaks every resource server that was not updated
`NimbusJwtDecoder`'s default JOSE type verifier accepts `JWT` and an absent `typ`, and nothing
else. The moment the authorization server starts typing tokens `at+jwt`, every service using
Boot's auto-configured decoder answers:
```
WWW-Authenticate: Bearer error="invalid_token",
error_description="An error occurred while attempting to decode the Jwt:
the given typ value needs to be one of [JWT]"
```
[`docs/output/05-strict-validation.txt`](output/05-strict-validation.txt) has the downstream
service accepting the new tokens and the edge service, one hop away and not updated, rejecting
them. The message mentions neither RFC 9068 nor the authorization server.
The fix on the receiving side:
```java
NimbusJwtDecoder.withJwkSetUri(jwks)
.jwtProcessorCustomizer((processor) -> processor.setJWSTypeVerifier(
new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"),
JOSEObjectType.JWT, null)))
.build();
```
Roll that out **before** you change the authorization server, not after.
## A message worth recognising twice
Two components produce almost the same sentence and mean opposite things:
| Source | Message |
|---|---|
| Nimbus `DefaultJOSEObjectTypeVerifier` | `the given typ value needs to be one of [JWT]` |
| Spring `JwtTypeValidator` (at+jwt profile) | `the given typ value needs to be one of [at+jwt, application/at+jwt]` |
The first means "your authorization server is too modern for this decoder". The second means
"your authorization server is not modern enough for this validator".
## And one thing that *is* on by default
`X509CertificateThumbprintValidator` is in the default set. If a token carries a `cnf` claim
with `x5t#S256` — a certificate-bound access token, RFC 8705 — Spring Security **already**
checks it against the client certificate on the TLS connection, with no configuration. That is
the strongest available defence against a stolen bearer token, it costs nothing on the resource
server, and it needs mTLS to be terminated in the application rather than in a sidecar. Chapter
6.
---
*Prev: [3. RestClient interceptors](03-restclient-interceptors.md) &middot; Next: [5. The gateway](05-the-gateway.md)*

View File

@@ -0,0 +1,74 @@
# 5. The gateway, and what `TokenRelay` actually relays
*Prev: [4. What is not validated](04-what-is-not-validated.md) &middot; Next: [6. mTLS](06-mtls.md)*
Spring Cloud Gateway Server MVC ships a filter that sounds like it solves the whole problem:
```yaml
filters:
- TokenRelay=
```
It is `TokenRelayFilterFunctions`, with two forms:
```java
public static HandlerFilterFunction<ServerResponse, ServerResponse> tokenRelay();
public static HandlerFilterFunction<ServerResponse, ServerResponse> tokenRelay(String clientRegistrationId);
```
The documentation is precise about what it does, and it is not what the name suggests to most
readers: it takes the access token **of the currently authenticated user** — the one obtained
by `oauth2Login()` — or of the named client registration, and puts it in the `Authorization`
header of the proxied request.
It does not mean "forward the incoming bearer token". There is nothing to forward from if the
gateway never performed a login.
## The A/B
`gateway.yml` defines two routes to the same destination, differing only in the filter.
[`docs/output/04-gateway-token-relay.txt`](output/04-gateway-token-relay.txt):
```
/edge/relay (TokenRelay=) → 200, sub: alice
/norelay/x (no TokenRelay) → 200, sub: alice
```
Identical. The token arrived because the gateway proxied the `Authorization` header the caller
sent, which it would have done anyway. In this configuration `TokenRelay` contributes nothing.
Where it earns its place is the other shape: a gateway that terminates a **browser session**
with `oauth2Login()`, keeps the tokens server-side, hands the browser nothing but a session
cookie, and attaches a token on the way through. That is the backend-for-frontend pattern, and
it is a genuinely good answer to "where do I keep the token in a SPA" — because the answer is
"not in the SPA".
## The 401 that has nothing to do with OAuth
Before `GatewaySecurityConfig` existed, every call through the gateway came back:
```
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
```
A perfectly valid bearer token, rejected by a gateway that had never been told to expect one.
Spring Boot applies a default filter chain — HTTP Basic and form login over every path — to any
application on the classpath with Spring Security and no `SecurityFilterChain` bean. A gateway
is an application. `WWW-Authenticate: Basic` in front of a token-based estate always means this.
## Choosing where the gateway sits
| Shape | Gateway does | Downstream sees |
|---|---|---|
| Pass-through | Routes; validates nothing | The caller's token; each service validates it |
| Edge validation | Validates the token, strips it, adds its own identity | The gateway's identity |
| BFF | `oauth2Login()`, holds tokens, `TokenRelay` | The user's token, obtained by the gateway |
Pass-through is the default and is fine while every service validates properly — chapter 4 is
about how often that assumption is wrong. Edge validation is the one that tempts teams into
"the gateway checked it, so we can trust the header", which is chapter 6's failure mode wearing
a different hat.
---
*Prev: [4. What is not validated](04-what-is-not-validated.md) &middot; Next: [6. mTLS](06-mtls.md)*

View File

@@ -0,0 +1,90 @@
# 6. mTLS: in the mesh or in the application
*Prev: [5. The gateway](05-the-gateway.md) &middot; Next: [7. Choosing](07-choosing.md)*
Run `./scripts/certs.sh`, then start `MtlsApplication` on 8443. The certificates it generates
are the point: `edge.crt` and `rogue.crt` have **identical subjects** and different issuers.
```
subject=CN = edge-service, OU = payments issuer=CN = Internal Mesh CA
subject=CN = edge-service, OU = payments issuer=CN = Some Other CA
```
Identity under mTLS is not the subject. It is the subject plus the fact that a CA in the trust
store vouched for it.
## In-application mTLS
```yaml
server:
ssl:
bundle: server
client-auth: need
spring:
ssl:
bundle:
pem:
server:
keystore: { certificate: "file:...server.crt", private-key: "file:...server.key" }
truststore: { certificate: "file:...internal-ca.crt" }
```
```java
http.x509((x509) -> x509
.subjectPrincipalRegex("CN=([^,]*)(?:,|$)")
.userDetailsService(certificateUsers()));
```
A good certificate produces a `PreAuthenticatedAuthenticationToken` with the CN as the
principal, and — in Spring Security 7 — a `FACTOR_X509` authority alongside the roles, the
sibling of the `FACTOR_BEARER` you get from a JWT and the `FACTOR_PASSWORD` you get from Basic.
Any assertion using `containsExactly` on authorities will fail on it.
## Three things the transcript shows that a diagram does not
[`docs/output/06-mtls.txt`](output/06-mtls.txt):
**The rogue certificate produces no HTTP status at all.** `curl` exits 56; there is no response
line, no 401, no 403, and nothing in the application log at `INFO`. The handshake failed. Your
application-level metrics will show nothing, because from the application's point of view
nothing happened. Debugging this means `-Djavax.net.debug=ssl:handshake` or the load balancer's
own logs.
**`client-auth: need` is a property of the connector, not of a path.** `/mtls/trusted-header` is
`permitAll()` and it fails exactly the same way without a certificate. You cannot expose a
public health endpoint on an mTLS-only connector; it needs a second connector, or `want`
instead of `need` plus an explicit authorization rule that treats an absent certificate as
anonymous.
**Header-based identity is verified by nothing.** The last call in the transcript presents a
valid certificate for `edge-service` and a header claiming to be `payments-service`, and the
endpoint reports `payments-service`.
## Mesh mTLS
A service mesh terminates TLS in a sidecar. The application receives plain HTTP on localhost and
the peer identity arrives as a header — `X-Forwarded-Client-Cert` in Envoy, carrying the SPIFFE
URI SAN. The trade:
| | Mesh | In-application |
|---|---|---|
| Certificate lifecycle | Handled, rotated automatically | Yours: issuance, rotation, expiry alerts |
| Application code | None | An SSL bundle plus `x509(..)` |
| Identity in the app | A header | A verified `X509Certificate` |
| Works with cert-bound tokens (RFC 8705) | **No** | Yes |
| Failure mode | Anything that bypasses the sidecar can spoof the header | Handshake failure, no HTTP status |
The row that decides it for a security-sensitive service is the RFC 8705 one. Certificate-bound
access tokens — the `cnf` / `x5t#S256` claim, which `X509CertificateThumbprintValidator`
already checks by default (chapter 4) — bind a token to the TLS connection it was issued for, so
a stolen token is useless without the private key. That binding requires the application to see
the client certificate. Terminate mTLS in a sidecar and the strongest defence available against
token theft is off the table.
If you take mesh identity from a header, the header must be **stripped at ingress** on every
path into the pod, and the pod must not be reachable except through the proxy. Both are
infrastructure guarantees that no amount of application code can verify — which is exactly why
`/mtls/trusted-header` answers `"verifiedBy": "nothing. This endpoint believes a header."`.
---
*Prev: [5. The gateway](05-the-gateway.md) &middot; Next: [7. Choosing](07-choosing.md)*

View File

@@ -0,0 +1,53 @@
# 7. Choosing
*Prev: [6. mTLS](06-mtls.md)*
## Which propagation strategy
| Situation | Use |
|---|---|
| One hop, same team, same trust boundary, audit needs the user | Relay |
| A job with no user: scheduler, message listener, reconciliation | Client credentials |
| The next hop is another team's service, and audit needs the user | Token exchange |
| The next hop is outside your organisation | Client credentials, and a token minted for them |
The honest default is **relay inside a boundary, exchange across one**. Client credentials is
the right answer more often than it is used for work with no user attached, and the wrong answer
whenever somebody will later ask "who did this?".
## Do you need any of this?
A callout that belongs in every article on this subject and is usually missing:
> **If your services are a single deployment behind one ingress, all of this is cost with no
> benefit.** Two Spring Boot applications in one VPC, called by one another, with no
> multi-tenancy and no external partner, do not need an authorization server, a token exchange
> grant or a mesh. Network-level isolation plus a shared secret is a defensible design, and it
> is a design you can reason about at 3am. The machinery in this repository earns its keep when
> there are enough services, enough teams, or enough regulatory pressure that "who called this,
> on whose behalf, with what permission" has to be answerable from a log rather than from
> memory.
## A checklist that is short on purpose
1. Every resource server validates **audience**, not just signature and expiry (chapter 4).
2. No relay reads `SecurityContextHolder` from a thread the request did not create (chapter 2).
3. Every service has a `SecurityFilterChain` bean, so nothing falls back to Boot's Basic default
(chapter 5).
4. Client registrations name `token-uri` / `jwk-set-uri` rather than `issuer-uri`, unless you
want a startup-ordering dependency (chapter 1).
5. If identity arrives in a header, something upstream strips that header from every external
request, and you can name the component that does it (chapter 6).
6. Token lifetimes are short enough that the audience gap in item 1 is bounded even when
somebody forgets.
## Further reading
- [Spring Security Context Propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/) — why the async relay returns 401
- [The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/) — where `BearerTokenAuthenticationFilter` sits
- [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) — token exchange
- [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) — the `at+jwt` access token profile
- [RFC 8705](https://datatracker.ietf.org/doc/html/rfc8705) — mTLS client authentication and certificate-bound access tokens
---
*Prev: [6. mTLS](06-mtls.md)*

View File

@@ -0,0 +1,26 @@
==============================================================================
docs/output/01-user-token.txt
A complete authorization_code + PKCE flow, driven by curl. No browser, no OIDC library.
scripts/user-token.sh, then scripts/claims.sh
==============================================================================
{
"alg": "RS256",
"kid": "<uuid>"
}
{
"aud": "downstream-api",
"exp": <epoch>,
"iat": <epoch>,
"iss": "http://127.0.0.1:9000",
"jti": "<uuid>",
"nbf": <epoch>,
"scope": [
"orders.write",
"orders.read"
],
"sub": "alice"
}
# sub is the human. scope is what the human consented to. aud names the service the
# token was minted for - chapter 4 is about whether anybody looks at it.

View File

@@ -0,0 +1,110 @@
==============================================================================
docs/output/02-five-strategies.txt
The same request into the edge service, five ways of getting a token for the hop to
downstream. Read the sub and scope of each downstream response.
GET /edge/{naive,relay,client-credentials,exchange,relay-async}
==============================================================================
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/naive
{
"strategy": "no token forwarded",
"error": "Unauthorized: 401 Unauthorized: [no body]"
}
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/relay
{
"strategy": "bearer token relayed from the incoming request",
"downstream": {
"service": "downstream:8082",
"strictValidation": false,
"sub": "alice",
"aud": [
"downstream-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.write, orders.read]",
"client_id": null,
"authorities": [
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
"SCOPE_orders.read",
"SCOPE_orders.write"
],
"cnf": null,
"orders": [
{
"total": "42.00",
"id": 1
}
]
}
}
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/client-credentials
{
"strategy": "the edge service's own client_credentials token",
"downstream": {
"service": "downstream:8082",
"strictValidation": false,
"sub": "edge-service",
"aud": [
"downstream-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.read]",
"client_id": null,
"authorities": [
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
"SCOPE_orders.read"
],
"cnf": null,
"orders": [
{
"total": "42.00",
"id": 1
}
]
}
}
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/exchange
{
"strategy": "RFC 8693 token exchange",
"downstream": {
"service": "downstream:8082",
"strictValidation": false,
"sub": "alice",
"aud": [
"downstream-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.read]",
"client_id": null,
"authorities": [
"SCOPE_orders.read",
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]"
],
"cnf": null,
"orders": [
{
"total": "42.00",
"id": 1
}
]
}
}
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/relay-async
{
"strategy": "relay attempted from a separate thread",
"error": "Unauthorized: 401 Unauthorized: [no body]"
}
# naive - 401. The control.
# relay - sub: alice, scope: [orders.write, orders.read]. The user's own
# token, unchanged, including scopes downstream did not need.
# client-creds - sub: edge-service, scope: [orders.read]. Correctly scoped, and
# the user has disappeared from downstream's audit log.
# exchange - sub: alice, scope: [orders.read]. Both. This is what RFC 8693 is
# for and it is the one nobody reaches for.
# relay-async - 401. The relay interceptor reads SecurityContextHolder, which is
# a ThreadLocal, and the call was made on a different thread.

View File

@@ -0,0 +1,53 @@
==============================================================================
docs/output/03-audience-ignored.txt
A token minted for a DIFFERENT service, presented to the downstream service.
Default validators.
==============================================================================
# the token reporting-service was issued:
{
"alg": "RS256",
"kid": "<uuid>"
}
{
"aud": "reporting-api",
"exp": <epoch>,
"iat": <epoch>,
"iss": "http://127.0.0.1:9000",
"jti": "<uuid>",
"nbf": <epoch>,
"scope": [
"orders.read"
],
"sub": "reporting-service"
}
$ curl -H 'Authorization: Bearer <reporting-service token>' 127.0.0.1:8082/orders
{
"service": "downstream:8082",
"strictValidation": false,
"sub": "reporting-service",
"aud": [
"reporting-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.read]",
"client_id": null,
"authorities": [
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
"SCOPE_orders.read"
],
"cnf": null,
"orders": [
{
"total": "42.00",
"id": 1
}
]
}
# HTTP 200. The aud claim says reporting-api. The service is downstream-api.
# JwtValidators.createDefault() is a DelegatingOAuth2TokenValidator over three
# validators - JwtTypeValidator, JwtTimestampValidator and
# X509CertificateThumbprintValidator. Structure, expiry, and certificate binding.
# No issuer. No audience. Read back by reflection in ValidatorContractTests.

View File

@@ -0,0 +1,80 @@
==============================================================================
docs/output/04-gateway-token-relay.txt
Spring Cloud Gateway Server MVC with 'filters: - TokenRelay='.
GET /edge/relay through the gateway on 8080.
==============================================================================
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8080/edge/relay
HTTP/1.1 200
cache-control: no-cache, no-store, max-age=0, must-revalidate
expires: 0
pragma: no-cache
x-content-type-options: nosniff
x-frame-options: DENY
x-xss-protection: 0
Content-Type: application/json
{
"strategy": "bearer token relayed from the incoming request",
"downstream": {
"service": "downstream:8082",
"strictValidation": false,
"sub": "alice",
"aud": [
"downstream-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.write, orders.read]",
"client_id": null,
"authorities": [
"SCOPE_orders.read",
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
"SCOPE_orders.write"
],
"cnf": null,
"orders": [
{
"total": "42.00",
"id": 1
}
]
}
}
$ curl 127.0.0.1:8080/edge/relay # no Authorization header at all
status 401
# and the identical route with the TokenRelay filter REMOVED:
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8080/norelay/x
{
"strategy": "bearer token relayed from the incoming request",
"downstream": {
"service": "downstream:8082",
"strictValidation": false,
"sub": "alice",
"aud": [
"downstream-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.write, orders.read]",
"client_id": null,
"authorities": [
"SCOPE_orders.read",
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
"SCOPE_orders.write"
],
"cnf": null,
"orders": [
{
"total": "42.00",
"id": 1
}
]
}
}
# TokenRelay relays the access token of the currently authenticated USER - the one
# obtained by oauth2Login(). This gateway has no oauth2Login, so there is no
# authorized client to read a token from, and the filter contributes nothing. What
# reaches the edge service is whatever Authorization header the caller sent, because
# the gateway proxied it. TokenRelay is not 'forward the incoming bearer token'.

View File

@@ -0,0 +1,43 @@
==============================================================================
docs/output/05-strict-validation.txt
The same tokens against JwtValidators.createAtJwtValidator().issuer(..).audience(..),
with the authorization server emitting RFC 9068 tokens (typ: at+jwt, client_id claim).
STRICT=true ./scripts/run.sh
==============================================================================
# the wrong-audience token that was accepted in 03:
HTTP/1.1 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://127.0.0.1:8082/.well-known/oauth-protected-resource"
# a token minted for this service:
{
"service": "downstream:8082",
"strictValidation": true,
"sub": "edge-service",
"aud": [
"downstream-api"
],
"iss": "http://127.0.0.1:9000",
"scope": "[orders.read]",
"client_id": "edge-service",
"authorities": [
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
"SCOPE_orders.read"
],
"cnf": null,
"orders": [
{
"id": 1,
"total": "42.00"
}
]
}
# and the edge service, which was NOT updated - it still uses Boot's
# auto-configured decoder:
HTTP/1.1 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the given typ value needs to be one of [JWT]", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://127.0.0.1:8081/.well-known/oauth-protected-resource"
# Turning on RFC 9068 at the authorization server broke every resource server that
# still has NimbusJwtDecoder's default JOSE type verifier, and the error message
# mentions neither RFC 9068 nor the authorization server.

View File

@@ -0,0 +1,43 @@
==============================================================================
docs/output/06-mtls.txt
Client-certificate authentication on port 8443, server.ssl.client-auth=need.
Certificates from scripts/certs.sh. edge.crt and rogue.crt have IDENTICAL subjects and
different issuers.
==============================================================================
$ openssl x509 -in target/certs/edge.crt -noout -subject -issuer
subject=CN = edge-service, OU = payments
issuer=CN = Internal Mesh CA
$ openssl x509 -in target/certs/rogue.crt -noout -subject -issuer
subject=CN = edge-service, OU = payments
issuer=CN = Some Other CA
$ curl --cert edge.crt --key edge.key https://localhost:8443/mtls/whoami
{
"principal": "edge-service",
"authenticationType": "PreAuthenticatedAuthenticationToken",
"authorities": [
"ROLE_SERVICE",
"FACTOR_X509"
],
"certificateSubject": "OU=payments,CN=edge-service",
"certificateIssuer": "CN=Internal Mesh CA"
}
$ curl --cert rogue.crt --key rogue.key https://localhost:8443/mtls/whoami
[curl exit 56, http 000]
$ curl https://localhost:8443/mtls/trusted-header # a permitAll() endpoint
[curl exit 56, http 000]
$ curl --cert edge.crt --key edge.key -H 'X-Client-Cert-Subject: CN=payments-service' \
https://localhost:8443/mtls/trusted-header
{"caller":"CN=payments-service","verifiedBy":"nothing. This endpoint believes a header."}
# Three things worth reading twice.
# 1. The rogue certificate fails with curl exit 56 and NO http status. The handshake
# is rejected; the application never sees a request and logs nothing at INFO.
# 2. So does the permitAll() endpoint. client-auth=need is a property of the
# CONNECTOR, not of a path. You cannot expose a public endpoint on that port.
# 3. The last call is what a mesh deployment usually looks like from inside the
# application: an identity taken from a header, verified by nothing.

View File

@@ -0,0 +1,8 @@
==============================================================================
docs/output/07-tests.txt
mvn -B test
==============================================================================
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.225 s -- in com.ankurm.s2s.ValidatorContractTests
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

View File

@@ -0,0 +1,82 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- Same parent as the other servlet modules in this repository, so the whole series runs
on one verified stack. Spring Cloud is a separate release train with its own Boot
baseline - see docs/01-the-four-processes.md for what that costs. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>service-to-service</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-cloud.version>2025.1.3</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Generate a throwaway CA, a server certificate for the downstream service, and two client
# certificates - one signed by that CA and one signed by a different CA. Into target/, so
# nothing here is committed and nothing here should ever be trusted.
#
# ./scripts/certs.sh
set -eu
cd "$(dirname "$0")/.."
D=target/certs
rm -rf "$D" && mkdir -p "$D"
cd "$D"
gen_ca() { # gen_ca <name> <cn>
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout "$1-ca.key" -out "$1-ca.crt" -subj "/CN=$2" 2>/dev/null
}
sign() { # sign <ca> <name> <subject> [extfile-content]
openssl req -newkey rsa:2048 -nodes -keyout "$2.key" -out "$2.csr" -subj "$3" 2>/dev/null
if [ -n "${4:-}" ]; then printf '%s\n' "$4" > "$2.ext"; else : > "$2.ext"; fi
openssl x509 -req -in "$2.csr" -CA "$1-ca.crt" -CAkey "$1-ca.key" -CAcreateserial \
-out "$2.crt" -days 3650 -extfile "$2.ext" 2>/dev/null
}
gen_ca internal "Internal Mesh CA"
gen_ca other "Some Other CA"
sign internal server "/CN=localhost" "subjectAltName=DNS:localhost,IP:127.0.0.1"
sign internal edge "/CN=edge-service/OU=payments"
sign other rogue "/CN=edge-service/OU=payments"
echo "wrote:"
ls -1 *.crt *.key | sed 's/^/ target\/certs\//'
echo
echo "Note that rogue.crt carries the SAME subject as edge.crt. Identity in mTLS is not the"
echo "subject; it is the subject plus the fact that a trusted CA vouched for it."

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Print the header and payload of a JWT without verifying it. For reading, not for trusting.
#
# ./scripts/claims.sh "$TOKEN"
set -eu
python3 - "$1" <<'PY'
import base64, json, sys
token = sys.argv[1]
for part in token.split('.')[:2]:
padded = part + '=' * (-len(part) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(padded)), indent=2, sort_keys=True))
PY

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/ from a real run. Nothing in docs/output/ is
# hand-written; if a claim in the article disagrees with a file here, the file is right.
#
# ./scripts/run-all.sh
#
# The whole module is started twice - once loose, once strict - plus a separate mTLS process,
# because the difference between those runs IS the content.
set -eu
cd "$(dirname "$0")/.."
OUT=docs/output
mkdir -p "$OUT"
hdr() { printf '%s\n%s\n%s\n\n' "$(printf '=%.0s' $(seq 1 78))" "$1" "$(printf '=%.0s' $(seq 1 78))"; }
scrub() {
sed -E \
-e 's/\r$//' \
-e 's/[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9:.]+(Z|\+[0-9:]+)?/<timestamp>/g' \
-e 's/"(exp|iat|nbf)": [0-9]+/"\1": <epoch>/g' \
-e 's/"(jti|kid)": "[0-9a-f-]+"/"\1": "<uuid>"/g' \
-e 's/(JSESSIONID=)[0-9A-F]+/\1<session>/g' \
-e 's/issuedAt=[^]]*\]/issuedAt=<timestamp>]/g' \
-e 's/ [0-9]+ --- / <pid> --- /g' \
-e '/Picked up JAVA_TOOL_OPTIONS/d' \
| cat -s
}
claims() { ./scripts/claims.sh "$1"; }
json() { python3 -m json.tool 2>/dev/null || cat; }
cc_token() { # cc_token <client> <secret>
curl -s -u "$1:$2" -X POST http://127.0.0.1:9000/oauth2/token \
-d grant_type=client_credentials -d scope=orders.read \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("access_token",""))'
}
########################################################################################
# LOOSE RUN
########################################################################################
./scripts/run.sh > /dev/null 2>&1
USER_TOKEN=$(./scripts/user-token.sh)
{
hdr "docs/output/01-user-token.txt
A complete authorization_code + PKCE flow, driven by curl. No browser, no OIDC library.
scripts/user-token.sh, then scripts/claims.sh"
claims "$USER_TOKEN"
echo
echo "# sub is the human. scope is what the human consented to. aud names the service the"
echo "# token was minted for - chapter 4 is about whether anybody looks at it."
} | scrub > "$OUT/01-user-token.txt"
{
hdr "docs/output/02-five-strategies.txt
The same request into the edge service, five ways of getting a token for the hop to
downstream. Read the sub and scope of each downstream response.
GET /edge/{naive,relay,client-credentials,exchange,relay-async}"
for endpoint in naive relay client-credentials exchange relay-async; do
echo "\$ curl -H \"Authorization: Bearer \$TOKEN\" 127.0.0.1:8081/edge/$endpoint"
curl -s -H "Authorization: Bearer $USER_TOKEN" "http://127.0.0.1:8081/edge/$endpoint" | json
echo
done
echo "# naive - 401. The control."
echo "# relay - sub: alice, scope: [orders.write, orders.read]. The user's own"
echo "# token, unchanged, including scopes downstream did not need."
echo "# client-creds - sub: edge-service, scope: [orders.read]. Correctly scoped, and"
echo "# the user has disappeared from downstream's audit log."
echo "# exchange - sub: alice, scope: [orders.read]. Both. This is what RFC 8693 is"
echo "# for and it is the one nobody reaches for."
echo "# relay-async - 401. The relay interceptor reads SecurityContextHolder, which is"
echo "# a ThreadLocal, and the call was made on a different thread."
} | scrub > "$OUT/02-five-strategies.txt"
{
hdr "docs/output/03-audience-ignored.txt
A token minted for a DIFFERENT service, presented to the downstream service.
Default validators."
WRONG=$(cc_token reporting-service reporting-secret)
echo "# the token reporting-service was issued:"
claims "$WRONG"
echo
echo "\$ curl -H 'Authorization: Bearer <reporting-service token>' 127.0.0.1:8082/orders"
curl -s -H "Authorization: Bearer $WRONG" http://127.0.0.1:8082/orders | json
echo
echo "# HTTP 200. The aud claim says reporting-api. The service is downstream-api."
echo "# JwtValidators.createDefault() is a DelegatingOAuth2TokenValidator over three"
echo "# validators - JwtTypeValidator, JwtTimestampValidator and"
echo "# X509CertificateThumbprintValidator. Structure, expiry, and certificate binding."
echo "# No issuer. No audience. Read back by reflection in ValidatorContractTests."
} | scrub > "$OUT/03-audience-ignored.txt"
{
hdr "docs/output/04-gateway-token-relay.txt
Spring Cloud Gateway Server MVC with 'filters: - TokenRelay='.
GET /edge/relay through the gateway on 8080."
echo "\$ curl -H \"Authorization: Bearer \$TOKEN\" 127.0.0.1:8080/edge/relay"
curl -s -i -H "Authorization: Bearer $USER_TOKEN" http://127.0.0.1:8080/edge/relay \
| sed -n '1,/^\r$/p' | grep -viE '^(date|keep-alive|connection|content-length|transfer-encoding):'
curl -s -H "Authorization: Bearer $USER_TOKEN" http://127.0.0.1:8080/edge/relay | json
echo
echo "\$ curl 127.0.0.1:8080/edge/relay # no Authorization header at all"
curl -s -o /dev/null -w 'status %{http_code}\n' http://127.0.0.1:8080/edge/relay
echo
echo "# and the identical route with the TokenRelay filter REMOVED:"
echo "\$ curl -H \"Authorization: Bearer \$TOKEN\" 127.0.0.1:8080/norelay/x"
curl -s -H "Authorization: Bearer $USER_TOKEN" http://127.0.0.1:8080/norelay/x | json
echo
echo "# TokenRelay relays the access token of the currently authenticated USER - the one"
echo "# obtained by oauth2Login(). This gateway has no oauth2Login, so there is no"
echo "# authorized client to read a token from, and the filter contributes nothing. What"
echo "# reaches the edge service is whatever Authorization header the caller sent, because"
echo "# the gateway proxied it. TokenRelay is not 'forward the incoming bearer token'."
} | scrub > "$OUT/04-gateway-token-relay.txt"
########################################################################################
# STRICT RUN
########################################################################################
STRICT=true ./scripts/run.sh > /dev/null 2>&1
{
hdr "docs/output/05-strict-validation.txt
The same tokens against JwtValidators.createAtJwtValidator().issuer(..).audience(..),
with the authorization server emitting RFC 9068 tokens (typ: at+jwt, client_id claim).
STRICT=true ./scripts/run.sh"
WRONG=$(cc_token reporting-service reporting-secret)
RIGHT=$(cc_token edge-service edge-secret)
echo "# the wrong-audience token that was accepted in 03:"
curl -s -i -H "Authorization: Bearer $WRONG" http://127.0.0.1:8082/orders \
| grep -iE '^(HTTP|WWW-Authenticate)'
echo
echo "# a token minted for this service:"
curl -s -H "Authorization: Bearer $RIGHT" http://127.0.0.1:8082/orders | json
echo
echo "# and the edge service, which was NOT updated - it still uses Boot's"
echo "# auto-configured decoder:"
STRICT_USER=$(./scripts/user-token.sh)
curl -s -i -H "Authorization: Bearer $STRICT_USER" http://127.0.0.1:8081/edge/relay \
| grep -iE '^(HTTP|WWW-Authenticate)'
echo
echo "# Turning on RFC 9068 at the authorization server broke every resource server that"
echo "# still has NimbusJwtDecoder's default JOSE type verifier, and the error message"
echo "# mentions neither RFC 9068 nor the authorization server."
} | scrub > "$OUT/05-strict-validation.txt"
./scripts/stop.sh
########################################################################################
# mTLS
########################################################################################
./scripts/certs.sh > /dev/null
CP="target/classes:$(cat target/cp.txt)"
setsid nohup java -Xmx160m -cp "$CP" com.ankurm.s2s.mtls.MtlsApplication \
> /tmp/s2s-Mtls.log 2>&1 < /dev/null &
for _ in $(seq 1 60); do
curl -s -o /dev/null -m 2 --cacert target/certs/internal-ca.crt \
--cert target/certs/edge.crt --key target/certs/edge.key \
https://localhost:8443/mtls/whoami && break
sleep 1
done
{
hdr "docs/output/06-mtls.txt
Client-certificate authentication on port 8443, server.ssl.client-auth=need.
Certificates from scripts/certs.sh. edge.crt and rogue.crt have IDENTICAL subjects and
different issuers."
echo "\$ openssl x509 -in target/certs/edge.crt -noout -subject -issuer"
openssl x509 -in target/certs/edge.crt -noout -subject -issuer
echo "\$ openssl x509 -in target/certs/rogue.crt -noout -subject -issuer"
openssl x509 -in target/certs/rogue.crt -noout -subject -issuer
echo
echo "\$ curl --cert edge.crt --key edge.key https://localhost:8443/mtls/whoami"
curl -s --cacert target/certs/internal-ca.crt --cert target/certs/edge.crt \
--key target/certs/edge.key https://localhost:8443/mtls/whoami | json
echo
echo "\$ curl --cert rogue.crt --key rogue.key https://localhost:8443/mtls/whoami"
curl -s --cacert target/certs/internal-ca.crt --cert target/certs/rogue.crt \
--key target/certs/rogue.key https://localhost:8443/mtls/whoami \
-w '[curl exit %{exitcode}, http %{http_code}]\n' 2>&1 | tail -1
echo
echo "\$ curl https://localhost:8443/mtls/trusted-header # a permitAll() endpoint"
curl -sk https://localhost:8443/mtls/trusted-header \
-w '[curl exit %{exitcode}, http %{http_code}]\n' 2>&1 | tail -1
echo
echo "\$ curl --cert edge.crt --key edge.key -H 'X-Client-Cert-Subject: CN=payments-service' \\"
echo " https://localhost:8443/mtls/trusted-header"
curl -s --cacert target/certs/internal-ca.crt --cert target/certs/edge.crt \
--key target/certs/edge.key -H 'X-Client-Cert-Subject: CN=payments-service' \
https://localhost:8443/mtls/trusted-header
echo
echo
echo "# Three things worth reading twice."
echo "# 1. The rogue certificate fails with curl exit 56 and NO http status. The handshake"
echo "# is rejected; the application never sees a request and logs nothing at INFO."
echo "# 2. So does the permitAll() endpoint. client-auth=need is a property of the"
echo "# CONNECTOR, not of a path. You cannot expose a public endpoint on that port."
echo "# 3. The last call is what a mesh deployment usually looks like from inside the"
echo "# application: an identity taken from a header, verified by nothing."
} | scrub > "$OUT/06-mtls.txt"
for pid in $(ps -eo pid,ppid,comm,args | awk '$3 ~ /^java/ && $0 ~ /com\.ankurm\.s2s\.mtls/ {print $1}'); do
kill -9 "$pid" 2>/dev/null || true
done
########################################################################################
# TESTS
########################################################################################
{
hdr "docs/output/07-tests.txt
mvn -B test"
mvn -B test 2>&1 | grep -E 'Tests run|ERROR|BUILD' | head -30
} | scrub > "$OUT/07-tests.txt"
echo "regenerated $(ls "$OUT" | wc -l) files under $OUT"

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Start all four processes and wait until each answers.
#
# ./scripts/run.sh
# STRICT=true ./scripts/run.sh # downstream validates issuer and audience
# RS_LOG_LEVEL=DEBUG ./scripts/run.sh # resource-server decisions at DEBUG
#
# | Process | Port | Main class |
# |-------------|------|---------------------------|
# | authserver | 9000 | AuthServerApplication |
# | gateway | 8080 | GatewayApplication |
# | edge | 8081 | EdgeApplication |
# | downstream | 8082 | DownstreamApplication |
#
# These are launched with plain `java`, not `spring-boot:run`. Four Maven JVMs each forking an
# application JVM is eight processes, and on a small machine that is how you meet the OOM
# killer rather than the demo. `mvn dependency:build-classpath` once, then `java -cp` four
# times, is two hundred megabytes of heap instead of two gigabytes.
set -eu
cd "$(dirname "$0")/.."
./scripts/stop.sh
mvn -B -q compile
if [ ! -f target/cp.txt ]; then
mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=runtime
fi
CP="target/classes:$(cat target/cp.txt)"
start() { # start <main-class> <port> <health-path> <extra-jvm-args...>
local main="$1" port="$2" path="$3"
shift 3
setsid nohup java -Xmx192m -XX:TieredStopAtLevel=1 "$@" ${JVM_ARGS:-} \
-cp "$CP" "com.ankurm.s2s.$main" \
> "/tmp/s2s-${main##*.}.log" 2>&1 < /dev/null &
for _ in $(seq 1 60); do
if curl -s -o /dev/null "http://127.0.0.1:$port$path" 2>/dev/null; then
echo " ${main##*.} up on $port"
return 0
fi
sleep 1
done
echo "${main##*.} did not start; see /tmp/s2s-${main##*.}.log" >&2
tail -20 "/tmp/s2s-${main##*.}.log" >&2
return 1
}
# STRICT=true turns on RFC 9068 end to end: the authorization server types its access tokens
# `at+jwt` and adds a client_id claim, and the downstream service validates issuer, audience
# and the required-claim set instead of only signature and expiry.
STRICT_ARGS=()
AS_ARGS=()
if [ "${STRICT:-false}" = "true" ]; then
STRICT_ARGS=(-DSTRICT=true)
AS_ARGS=(-DAT_JWT=true)
fi
# The authorization server must be first and must be READY before the others: `edge` is an
# OAuth2 client, and a client whose provider is configured with issuer-uri fetches the
# discovery document during context refresh. See docs/01-the-four-processes.md.
start authserver.AuthServerApplication 9000 /oauth2/jwks "${AS_ARGS[@]}"
start downstream.DownstreamApplication 8082 /orders "${STRICT_ARGS[@]}"
start edge.EdgeApplication 8081 /edge/naive
start gateway.GatewayApplication 8080 /edge/naive
echo "all four up"

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Stop every process in the module.
#
# Two rules, both learned the hard way while building this repository:
#
# 1. Match the MAIN CLASS, never 'spring-boot' or 'java'. `pkill -f spring-boot` also matches
# the shell command line that launched the application, so it kills your own shell.
#
# 2. Restrict the match to processes that are actually a JVM, and exclude this script and its
# parent. A bracketed pattern like '[A]uthServerApplication' stops the pattern matching the
# grep itself - but it does NOT stop it matching an ancestor shell whose command line
# happens to contain that string, which is exactly what happens when you paste a here-doc
# containing the class name into a terminal and then run this script from it. The victim
# process dies with exit 137 and no output, which is a memorable afternoon.
set -eu
SELF=$$
PARENT=$PPID
ps -eo pid,ppid,comm,args | awk -v self="$SELF" -v parent="$PARENT" '
$1 != self && $1 != parent && $3 ~ /^java/ && $0 ~ /com\.ankurm\.s2s\./ { print $1 }
' | while read -r pid; do
kill -9 "$pid" 2>/dev/null || true
done
sleep 1

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Drive a complete authorization_code + PKCE flow with curl and print the access token.
#
# TOKEN=$(./scripts/user-token.sh)
#
# There is no browser here and none is needed: the "browser flow" is four HTTP requests and a
# cookie jar. Doing it by hand once is the fastest way to understand what your SPA's OIDC
# library is actually doing, and it makes every transcript in docs/output/ reproducible.
set -eu
AS=http://127.0.0.1:9000
JAR=$(mktemp)
trap 'rm -f "$JAR"' EXIT
# 1. PKCE: a random verifier, and its base64url-encoded SHA-256 as the challenge.
VERIFIER=$(head -c 48 /dev/urandom | base64 | tr -d '=+/' | cut -c1-64)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | base64 | tr '+/' '-_' | tr -d '=')
AUTHORIZE="$AS/oauth2/authorize?response_type=code&client_id=spa-client\
&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauthorized&scope=orders.read%20orders.write\
&code_challenge=$CHALLENGE&code_challenge_method=S256"
# 2. Ask for the code. Unauthenticated, so this saves the request and redirects to /login.
curl -s -o /dev/null -c "$JAR" -b "$JAR" "$AUTHORIZE"
# 3. Log in. The login page is CSRF-protected, so read the token out of the form.
CSRF=$(curl -s -c "$JAR" -b "$JAR" "$AS/login" \
| grep -oiE 'name="_csrf"[^>]*value="[^"]*"' | head -1 | sed 's/.*value="//; s/"//')
curl -s -o /dev/null -c "$JAR" -b "$JAR" -X POST "$AS/login" \
-d "username=alice" -d "password=password" -d "_csrf=$CSRF"
# 4. Follow the saved request. Now authenticated, so this redirects to the redirect_uri
# carrying ?code=... We never let curl follow it; we just read the Location header.
CODE=$(curl -s -o /dev/null -D- -c "$JAR" -b "$JAR" "$AUTHORIZE" \
| grep -i '^location:' | sed 's/.*code=//; s/[&\r].*//')
if [ -z "$CODE" ]; then
echo "no authorization code was issued - is the auth server up?" >&2
exit 1
fi
# 5. Redeem it. A public client, so no client secret: the code_verifier is the proof.
curl -s -X POST "$AS/oauth2/token" \
-d grant_type=authorization_code \
-d "code=$CODE" \
-d "redirect_uri=http://127.0.0.1:8080/authorized" \
-d client_id=spa-client \
-d "code_verifier=$VERIFIER" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'

View File

@@ -0,0 +1,205 @@
package com.ankurm.s2s.authserver;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.util.UUID;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
/**
* A real Spring Authorization Server, on port 9000. Everything downstream in this module is
* validated against tokens this process minted, so nothing in the article is a hand-written JWT.
*
* <p>Three clients are registered, one per pattern the article covers:
* <ul>
* <li>{@code spa-client} &mdash; authorization_code + PKCE. This is where the <em>user's</em>
* token comes from. {@code scripts/user-token.sh} drives the whole browser flow with curl.</li>
* <li>{@code edge-service} &mdash; client_credentials. The edge service's own identity, with no
* user anywhere in it.</li>
* <li>{@code edge-exchange} &mdash; urn:ietf:params:oauth:grant-type:token-exchange (RFC 8693),
* for trading the user's token for one scoped to the downstream service.</li>
* </ul>
*
* <p>Tokens carry an {@code aud} claim naming {@code downstream-api}. Chapter 4 is about what
* happens to that claim by default, which is nothing.
*
* <p>See <a href="../../../../../docs/01-the-four-processes.md">docs/01-the-four-processes.md</a>.
*/
@SpringBootApplication
public class AuthServerApplication {
public static void main(String[] args) {
System.setProperty("spring.config.name", "authserver");
SpringApplication.run(AuthServerApplication.class, args);
}
/**
* Note what this is <em>not</em>. Every Spring Authorization Server tutorial written
* before 7.0 starts with
* {@code OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)}. That class,
* and the whole
* {@code org.springframework.security.oauth2.server.authorization.config.annotation.web.*}
* package tree, is <b>absent</b> from {@code spring-security-oauth2-authorization-server}
* 7.1.1. The configurer moved into {@code spring-security-config} at
* {@code org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization},
* and the entry point is now the DSL method {@code HttpSecurity.oauth2AuthorizationServer(..)}.
* The old call is a compile error, not a deprecation.
*/
@Bean
@Order(1)
SecurityFilterChain authorizationServerChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer configurer = new OAuth2AuthorizationServerConfigurer();
return http
.securityMatcher(configurer.getEndpointsMatcher())
.with(configurer, (server) -> server.oidc(Customizer.withDefaults()))
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
.csrf((csrf) -> csrf.ignoringRequestMatchers(configurer.getEndpointsMatcher()))
.exceptionHandling((exceptions) -> exceptions
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
.build();
}
@Bean
@Order(2)
SecurityFilterChain loginChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.build();
}
@Bean
UserDetailsService users() {
return new InMemoryUserDetailsManager(
User.withUsername("alice").password("{noop}password").roles("USER").build());
}
@Bean
RegisteredClientRepository registeredClients() {
TokenSettings tokens = TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
.accessTokenTimeToLive(Duration.ofMinutes(10))
.build();
RegisteredClient spa = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("spa-client")
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("http://127.0.0.1:8080/authorized")
.scope("orders.read")
.scope("orders.write")
.tokenSettings(tokens)
// Explicit, because the default is not what the property name suggests - see
// docs/01-the-four-processes.md. Without this the authorization_code flow stops
// at a consent page even for a client that never asked for consent.
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
.build();
RegisteredClient edge = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("edge-service")
.clientSecret("{noop}edge-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("orders.read")
.tokenSettings(tokens)
.build();
RegisteredClient exchange = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("edge-exchange")
.clientSecret("{noop}exchange-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
.scope("orders.read")
.tokenSettings(tokens)
.build();
// A client that has nothing to do with the downstream service. Its tokens carry
// aud: reporting-api. Chapter 4 sends one of them to the downstream service and
// watches it be accepted.
RegisteredClient reporting = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("reporting-service")
.clientSecret("{noop}reporting-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("orders.read")
.tokenSettings(tokens)
.build();
return new InMemoryRegisteredClientRepository(spa, edge, exchange, reporting);
}
/**
* Adds {@code aud: downstream-api} to every access token, so chapter 4 has something to not
* validate.
*/
@Bean
OAuth2TokenCustomizer<JwtEncodingContext> audienceCustomizer() {
return (context) -> {
if (!"access_token".equals(context.getTokenType().getValue())) {
return;
}
String clientId = context.getRegisteredClient().getClientId();
// Every token names the service it was minted for. Whether the receiving service
// looks at that claim is chapter 4, and the answer by default is no.
String audience = "reporting-service".equals(clientId) ? "reporting-api" : "downstream-api";
context.getClaims().audience(java.util.List.of(audience));
// RFC 9068 says an access token should be typed `at+jwt` and carry `client_id`.
// Spring Authorization Server emits neither by default, and
// JwtValidators.createAtJwtValidator() requires both. Behind a flag, because
// turning it on breaks a stock NimbusJwtDecoder - see
// docs/04-what-is-not-validated.md.
if (Boolean.getBoolean("AT_JWT")) {
context.getJwsHeader().type("at+jwt");
context.getClaims().claim("client_id", clientId);
}
};
}
@Bean
JWKSource<SecurityContext> jwkSource() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair keyPair = generator.generateKeyPair();
RSAKey key = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
.privateKey((RSAPrivateKey) keyPair.getPrivate())
.keyID(UUID.randomUUID().toString())
.build();
return new ImmutableJWKSet<>(new JWKSet(key));
}
@Bean
AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().issuer("http://127.0.0.1:9000").build();
}
}

View File

@@ -0,0 +1,126 @@
package com.ankurm.s2s.downstream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* The service at the end of the chain, on port 8082. It is a plain resource server, and its job
* in this repository is to report <em>who it thinks is calling</em>.
*
* <p>{@code /orders} echoes back the {@code sub}, {@code aud}, {@code scope} and {@code client_id}
* of the token that reached it. That is the whole demonstration: relay a user's token and
* {@code sub} is {@code alice}; use client credentials and {@code sub} is {@code edge-service}
* and the user has disappeared from the system of record.
*
* <p>Run with {@code -DSTRICT=true} to install the audience and issuer validation that the
* defaults leave out &mdash; see
* <a href="../../../../../docs/04-what-is-not-validated.md">docs/04-what-is-not-validated.md</a>.
*/
@SpringBootApplication
@RestController
public class DownstreamApplication {
public static void main(String[] args) {
System.setProperty("spring.config.name", "downstream");
SpringApplication.run(DownstreamApplication.class, args);
}
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/orders/**").hasAuthority("SCOPE_orders.read")
.anyRequest().authenticated())
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()))
.csrf((csrf) -> csrf.disable())
.build();
}
/**
* The decoder. Which validators it carries is the entire subject of chapter 4.
*
* <p>{@code JwtValidators.createDefault()} is a {@code DelegatingOAuth2TokenValidator} over
* three things: {@code JwtTypeValidator}, {@code JwtTimestampValidator} and
* {@code X509CertificateThumbprintValidator}. No issuer. No audience. Boot's
* auto-configuration adds an issuer validator when you set {@code issuer-uri}; nothing adds
* an audience validator unless you do.
*/
@Bean
JwtDecoder jwtDecoder() {
boolean strict = Boolean.getBoolean("STRICT");
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("http://127.0.0.1:9000/oauth2/jwks")
// NimbusJwtDecoder's default JOSE type verifier accepts `JWT` and an absent
// `typ`, and nothing else. An RFC 9068-compliant authorization server types its
// access tokens `at+jwt`, and against the default decoder every one of them is
// rejected with "the given typ value needs to be one of [JWT]" - a message that
// says nothing about RFC 9068 and reads like a corrupt token.
.jwtProcessorCustomizer((processor) -> processor.setJWSTypeVerifier(
new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"),
JOSEObjectType.JWT, null)))
.build();
OAuth2TokenValidator<Jwt> validators = strict
// createAtJwtValidator() enforces the RFC 9068 profile: typ, exp, sub, iat,
// jti, iss, aud and client_id must all be present, on top of the issuer and
// audience values named here. A token from an authorization server that does
// not emit client_id fails this even when the issuer and audience are right.
? JwtValidators.createAtJwtValidator()
.issuer("http://127.0.0.1:9000")
.audience("downstream-api")
.build()
// The default: a DelegatingOAuth2TokenValidator over three validators, read
// back by reflection in ValidatorContractTests - JwtTypeValidator,
// JwtTimestampValidator and X509CertificateThumbprintValidator. Note what is
// NOT in that list. No issuer. No audience. No scope. A structurally valid,
// unexpired token minted by this issuer for a completely different service
// passes, which is docs/output/03-audience-ignored.txt.
: JwtValidators.createDefault();
decoder.setJwtValidator(validators);
return decoder;
}
@GetMapping("/orders")
Map<String, Object> orders(Authentication authentication) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("service", "downstream:8082");
body.put("strictValidation", Boolean.getBoolean("STRICT"));
if (authentication instanceof JwtAuthenticationToken token) {
Jwt jwt = token.getToken();
body.put("sub", jwt.getSubject());
body.put("aud", jwt.getAudience());
body.put("iss", String.valueOf(jwt.getIssuer()));
body.put("scope", jwt.getClaimAsString("scope"));
body.put("client_id", jwt.getClaimAsString("client_id"));
body.put("authorities", token.getAuthorities().stream().map(Object::toString).toList());
body.put("cnf", jwt.getClaim("cnf"));
}
body.put("orders", List.of(Map.of("id", 1, "total", "42.00")));
return body;
}
@GetMapping("/orders/whoami")
Map<String, Object> whoami(Authentication authentication) {
return Map.of("principal", authentication.getName(), "type",
authentication.getClass().getSimpleName());
}
}

View File

@@ -0,0 +1,45 @@
package com.ankurm.s2s.edge;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.TokenExchangeOAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
/**
* The {@code OAuth2AuthorizedClientManager} the interceptor asks for tokens.
*
* <p>Two details matter and both are easy to get wrong.
*
* <p><b>Which manager.</b> {@code DefaultOAuth2AuthorizedClientManager} is request-scoped and
* needs an {@code HttpServletRequest}; it is for a browser-facing client. For service-to-service
* calls, where there is no end user whose consent is being managed, the right one is
* {@code AuthorizedClientServiceOAuth2AuthorizedClientManager}, which stores authorized clients
* in an {@code OAuth2AuthorizedClientService} and works on any thread.
*
* <p><b>Which providers.</b> The builder's defaults do not include token exchange, so
* {@code TokenExchangeOAuth2AuthorizedClientProvider} has to be added explicitly. Leave it out
* and the {@code edge-exchange} registration silently produces no token &mdash; the manager
* returns {@code null} rather than raising anything.
*/
@Configuration
public class ClientManagerConfig {
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository registrations,
OAuth2AuthorizedClientService clientService) {
var provider = OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.refreshToken()
.provider(new TokenExchangeOAuth2AuthorizedClientProvider())
.build();
var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clientService);
manager.setAuthorizedClientProvider(provider);
return manager;
}
}

View File

@@ -0,0 +1,89 @@
package com.ankurm.s2s.edge;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import static org.springframework.security.oauth2.client.web.client.RequestAttributeClientRegistrationIdResolver.clientRegistrationId;
/**
* Four {@link RestClient} instances, differing only in what they put in the
* {@code Authorization} header. Everything else &mdash; base URL, path, error handling &mdash;
* is identical, so the transcripts differ in exactly one variable.
*
* <p>See <a href="../../../../../docs/03-restclient-interceptors.md">docs/03-restclient-interceptors.md</a>.
*/
@Component
public class DownstreamClients {
private static final String DOWNSTREAM = "http://127.0.0.1:8082";
private final RestClient plain;
private final RestClient relay;
private final RestClient managed;
public DownstreamClients(RestClient.Builder builder,
OAuth2AuthorizedClientManager authorizedClientManager) {
this.plain = builder.clone().baseUrl(DOWNSTREAM).build();
// Hand-rolled relay. Ten lines, no dependency on the OAuth2 client machinery, and it
// forwards the caller's identity unchanged - which is exactly what you want for an
// internal hop and exactly what you do NOT want for a call that leaves your trust
// boundary. The token's audience and scope were minted for the original request.
this.relay = builder.clone()
.baseUrl(DOWNSTREAM)
.requestInterceptor((request, body, execution) -> {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken token) {
request.getHeaders().setBearerAuth(token.getToken().getTokenValue());
}
return execution.execute(request, body);
})
.build();
// The framework's interceptor. It resolves a ClientRegistration id from a request
// attribute, asks the OAuth2AuthorizedClientManager for a token under that
// registration, caches it, and refreshes it when it expires. Which registration is
// chosen per call, via clientRegistrationId(..) below.
this.managed = builder.clone()
.baseUrl(DOWNSTREAM)
.requestInterceptor(new OAuth2ClientHttpRequestInterceptor(authorizedClientManager))
.build();
}
public Object plain() {
return get(this.plain, null);
}
public Object relayed() {
return get(this.relay, null);
}
public Object clientCredentials() {
return get(this.managed, "edge-service");
}
public Object exchanged() {
return get(this.managed, "edge-exchange");
}
@SuppressWarnings("unchecked")
private static Object get(RestClient client, String registrationId) {
var request = client.get().uri("/orders");
if (registrationId != null) {
request = request.attributes(clientRegistrationId(registrationId));
}
return request.header(HttpHeaders.ACCEPT, "application/json")
.retrieve()
.body(Map.class);
}
}

View File

@@ -0,0 +1,115 @@
package com.ankurm.s2s.edge;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* The middle service, on port 8081. It is simultaneously a resource server (it validates the
* token that arrives) and an OAuth2 client (it needs a token to call
* {@code downstream}). Every strategy for producing that second token is an endpoint here, so
* the difference between them is a diff of two JSON bodies rather than an argument.
*
* <table>
* <caption>Endpoints</caption>
* <tr><th>Endpoint</th><th>Strategy</th></tr>
* <tr><td>{@code /edge/naive}</td><td>No propagation at all &mdash; the control</td></tr>
* <tr><td>{@code /edge/relay}</td><td>Relay the incoming bearer token</td></tr>
* <tr><td>{@code /edge/client-credentials}</td><td>The service's own token</td></tr>
* <tr><td>{@code /edge/exchange}</td><td>RFC 8693 token exchange</td></tr>
* <tr><td>{@code /edge/relay-async}</td><td>Relay from a different thread &mdash; the trap</td></tr>
* </table>
*
* <p>See <a href="../../../../../docs/02-three-ways-to-get-a-token.md">docs/02-three-ways-to-get-a-token.md</a>.
*/
@SpringBootApplication
@RestController
public class EdgeApplication {
public static void main(String[] args) {
System.setProperty("spring.config.name", "edge");
SpringApplication.run(EdgeApplication.class, args);
}
private final DownstreamClients clients;
public EdgeApplication(DownstreamClients clients) {
this.clients = clients;
}
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()))
.csrf((csrf) -> csrf.disable())
.build();
}
@GetMapping("/edge/naive")
Map<String, Object> naive() {
return wrap("no token forwarded", this.clients::plain);
}
@GetMapping("/edge/relay")
Map<String, Object> relay() {
return wrap("bearer token relayed from the incoming request", this.clients::relayed);
}
@GetMapping("/edge/client-credentials")
Map<String, Object> clientCredentials() {
return wrap("the edge service's own client_credentials token", this.clients::clientCredentials);
}
@GetMapping("/edge/exchange")
Map<String, Object> exchange() {
return wrap("RFC 8693 token exchange", this.clients::exchanged);
}
/**
* The trap. The relay interceptor reads the token from {@code SecurityContextHolder}, which
* is a {@code ThreadLocal}. Make the downstream call on a thread the request did not create
* and there is nothing there to read.
*/
@GetMapping("/edge/relay-async")
Map<String, Object> relayAsync() {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
return wrap("relay attempted from a separate thread",
() -> executor.submit(this.clients::relayed).get());
}
catch (Exception ex) {
return Map.of("strategy", "relay attempted from a separate thread",
"error", unwrap(ex));
}
}
private static Map<String, Object> wrap(String strategy, Callable<Object> call) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("strategy", strategy);
try {
out.put("downstream", call.call());
}
catch (Exception ex) {
out.put("error", unwrap(ex));
}
return out;
}
private static String unwrap(Throwable throwable) {
Throwable root = throwable;
while (root.getCause() != null) {
root = root.getCause();
}
return root.getClass().getSimpleName() + ": " + root.getMessage();
}
}

View File

@@ -0,0 +1,27 @@
package com.ankurm.s2s.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Spring Cloud Gateway Server MVC, on port 8080, configured entirely in
* {@code src/main/resources/gateway.yml}.
*
* <p>The point of interest is the {@code TokenRelay} filter, which Spring Cloud provides as
* {@code TokenRelayFilterFunctions.tokenRelay()} and
* {@code TokenRelayFilterFunctions.tokenRelay(String clientRegistrationId)}. It takes the access
* token of the currently authenticated user &mdash; or of the named client registration &mdash;
* and puts it in the {@code Authorization} header of the proxied request.
*
* <p>Read <a href="../../../../../docs/05-the-gateway.md">docs/05-the-gateway.md</a> before
* copying any of this: Spring Cloud is a separate release train with its own Boot baseline, and
* the gateway's own {@code TokenRelay} does something narrower than the name suggests.
*/
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
System.setProperty("spring.config.name", "gateway");
SpringApplication.run(GatewayApplication.class, args);
}
}

View File

@@ -0,0 +1,32 @@
package com.ankurm.s2s.gateway;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* The gateway does not authenticate anything. It proxies.
*
* <p>This bean exists because without it Boot applies its default chain &mdash; HTTP Basic and
* form login over every path &mdash; and a caller presenting a perfectly good
* {@code Authorization: Bearer} header receives {@code 401 WWW-Authenticate: Basic} from the
* gateway, having never reached anything. The token was fine. The gateway had never been told
* what to do with it.
*
* <p>A real gateway is usually the opposite of this: it terminates the user session with
* {@code oauth2Login()} and issues nothing downstream but a token it obtained itself. That
* configuration is what Spring Cloud's {@code TokenRelay} filter is written for &mdash; see
* <a href="../../../../../docs/05-the-gateway.md">docs/05-the-gateway.md</a>.
*/
@Configuration
public class GatewaySecurityConfig {
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
.csrf((csrf) -> csrf.disable())
.build();
}
}

View File

@@ -0,0 +1,95 @@
package com.ankurm.s2s.mtls;
import java.util.LinkedHashMap;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* A resource server that authenticates callers by client certificate rather than by token, on
* port 8443. It exists so the article's mTLS section is a transcript rather than a diagram.
*
* <p>Run {@code ./scripts/certs.sh} first. Then:
*
* <pre>
* curl --cacert target/certs/internal-ca.crt \
* --cert target/certs/edge.crt --key target/certs/edge.key \
* https://localhost:8443/mtls/whoami
* </pre>
*
* <p>{@code rogue.crt} carries an identical subject and a different issuer, and the TLS
* handshake &mdash; not Spring Security &mdash; is what rejects it.
*
* <p>{@code /mtls/trusted-header} is the counterpart: an endpoint that believes an
* {@code X-Client-Cert-Subject} header, the way an application behind a service mesh usually
* does. See <a href="../../../../../docs/06-mtls.md">docs/06-mtls.md</a>.
*/
@SpringBootApplication
@RestController
public class MtlsApplication {
public static void main(String[] args) {
System.setProperty("spring.config.name", "mtls");
SpringApplication.run(MtlsApplication.class, args);
}
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/mtls/trusted-header").permitAll()
.anyRequest().authenticated())
// x509() maps the certificate's subject to a principal name and looks it up in a
// UserDetailsService. The certificate was already verified against the trust store
// by the TLS layer before any of this ran; Spring Security is deciding what the
// verified identity is allowed to do, not whether the certificate is genuine.
.x509((x509) -> x509
.subjectPrincipalRegex("CN=([^,]*)(?:,|$)")
.userDetailsService(certificateUsers()))
.csrf((csrf) -> csrf.disable())
.build();
}
UserDetailsService certificateUsers() {
return (username) -> User.withUsername(username).password("{noop}unused").roles("SERVICE").build();
}
@GetMapping("/mtls/whoami")
Map<String, Object> whoami(Authentication authentication, HttpServletRequest request) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("principal", authentication.getName());
out.put("authenticationType", authentication.getClass().getSimpleName());
out.put("authorities", authentication.getAuthorities().stream().map(Object::toString).toList());
var chain = (java.security.cert.X509Certificate[]) request
.getAttribute("jakarta.servlet.request.X509Certificate");
if (chain != null && chain.length > 0) {
out.put("certificateSubject", chain[0].getSubjectX500Principal().getName());
out.put("certificateIssuer", chain[0].getIssuerX500Principal().getName());
}
return out;
}
/**
* What an application inside a mesh usually does: trust a header the sidecar is supposed to
* have set. Nothing here verifies that the header came from the sidecar. If anything can
* reach this port without going through the proxy &mdash; another pod, a debug
* port-forward, a misconfigured NetworkPolicy &mdash; it can name itself whatever it likes.
*/
@GetMapping("/mtls/trusted-header")
Map<String, Object> trustedHeader(HttpServletRequest request) {
String claimed = request.getHeader("X-Client-Cert-Subject");
return Map.of("caller", (claimed != null) ? claimed : "(header absent)",
"verifiedBy", "nothing. This endpoint believes a header.");
}
}

View File

@@ -0,0 +1,8 @@
server:
port: 9000
spring:
application:
name: authserver
logging:
level:
org.springframework.security.oauth2.server.authorization: ${AS_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,17 @@
server:
port: 8082
spring:
application:
name: downstream
security:
oauth2:
resourceserver:
jwt:
# Deliberately jwk-set-uri and not issuer-uri. With issuer-uri, Boot builds the
# decoder from the discovery document and installs a JwtIssuerValidator; the bean in
# DownstreamApplication overrides that so the article can show the two validator sets
# side by side. Chapter 4.
jwk-set-uri: http://127.0.0.1:9000/oauth2/jwks
logging:
level:
org.springframework.security.oauth2.server.resource: ${RS_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,39 @@
server:
port: 8081
spring:
application:
name: edge
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://127.0.0.1:9000/oauth2/jwks
client:
provider:
local:
# Deliberately NOT issuer-uri. `issuer-uri` makes ClientRegistrations fetch
# /.well-known/openid-configuration during context refresh, so this service cannot
# start unless the authorization server is already up - a hard startup-ordering
# dependency between two deployments, with a stack trace ending in
# `ResourceAccessException: I/O error on GET request for
# ".../.well-known/openid-configuration": Connection refused` rather than
# anything that mentions ordering. Naming the endpoints removes it.
# See docs/01-the-four-processes.md.
token-uri: http://127.0.0.1:9000/oauth2/token
jwk-set-uri: http://127.0.0.1:9000/oauth2/jwks
registration:
edge-service:
provider: local
client-id: edge-service
client-secret: edge-secret
authorization-grant-type: client_credentials
scope: orders.read
edge-exchange:
provider: local
client-id: edge-exchange
client-secret: exchange-secret
authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange
scope: orders.read
logging:
level:
org.springframework.security.oauth2.client: ${CLIENT_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,32 @@
server:
port: 8080
spring:
application:
name: gateway
cloud:
gateway:
server:
webmvc:
routes:
# Two routes to the same place. The only difference is the TokenRelay filter,
# which is the point: compare docs/output/04-gateway-token-relay.txt.
- id: edge-with-relay
uri: http://127.0.0.1:8081
predicates:
- Path=/edge/**
filters:
# TokenRelay with no argument forwards the CURRENTLY AUTHENTICATED USER's
# access token - the one obtained by oauth2Login(). It does not forward an
# incoming bearer token; the incoming header is simply proxied like any other.
# Chapter 5.
- TokenRelay=
- id: edge-without-relay
uri: http://127.0.0.1:8081
predicates:
- Path=/norelay/**
filters:
- StripPrefix=1
- SetPath=/edge/relay
logging:
level:
org.springframework.cloud.gateway: ${GATEWAY_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,26 @@
server:
port: 8443
ssl:
bundle: server
# NEED, not WANT. With `want`, a client that presents no certificate still completes the
# handshake and arrives unauthenticated - which is what you want if some endpoints are
# public, and a silent hole if you assumed the connector was doing the authorizing.
client-auth: need
spring:
application:
name: mtls
ssl:
bundle:
pem:
server:
keystore:
certificate: file:target/certs/server.crt
private-key: file:target/certs/server.key
truststore:
# The list of CAs whose certificates this service will accept. This file is the
# entire trust decision. rogue.crt has the same subject as edge.crt and a
# different issuer, and this is the line that tells them apart.
certificate: file:target/certs/internal-ca.crt
logging:
level:
org.springframework.security.web.authentication.preauth.x509: ${X509_LOG_LEVEL:INFO}

View File

@@ -0,0 +1,158 @@
package com.ankurm.s2s;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtValidators;
import static org.assertj.core.api.Assertions.assertThat;
/**
* What the JWT validators actually check. These assertions exist because the answer surprised
* me while writing the companion repository, and because a future Spring Security release that
* changes the default set should break a test rather than a paragraph.
*
* <p>The full end-to-end evidence is in {@code docs/output/03-audience-ignored.txt} and
* {@code docs/output/05-strict-validation.txt}; these are the same facts pinned in-process.
*/
class ValidatorContractTests {
/** An access token typed the way RFC 9068 requires. */
private static Jwt atJwt(Map<String, Object> claims) {
Jwt.Builder builder = Jwt.withTokenValue("token")
.header("alg", "RS256")
.header("typ", "at+jwt")
.issuedAt(Instant.now().minusSeconds(60))
.expiresAt(Instant.now().plusSeconds(600))
.subject("alice");
claims.forEach(builder::claim);
return builder.build();
}
@Test
@DisplayName("the at+jwt validator rejects a token typed JWT, with a message one word from the Nimbus one")
void atJwtRejectsPlainJwtType() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createAtJwtValidator()
.issuer("http://127.0.0.1:9000")
.audience("downstream-api")
.build();
Jwt plain = jwt(Map.of("aud", List.of("downstream-api"), "iss", "http://127.0.0.1:9000",
"jti", "id", "client_id", "edge-service"));
var result = validator.validate(plain);
assertThat(result.hasErrors()).isTrue();
// Spring's JwtTypeValidator: "the given typ value needs to be one of
// [at+jwt, application/at+jwt]"
// Nimbus's DefaultJOSEObjectTypeVerifier, one layer lower: "the given typ value needs
// to be one of [JWT]"
// Nearly the same sentence from two different components, meaning opposite things.
assertThat(result.getErrors().iterator().next().getDescription())
.contains("at+jwt", "application/at+jwt");
}
private static Jwt jwt(Map<String, Object> claims) {
Jwt.Builder builder = Jwt.withTokenValue("token")
.header("alg", "RS256")
.issuedAt(Instant.now().minusSeconds(60))
.expiresAt(Instant.now().plusSeconds(600))
.subject("alice");
claims.forEach(builder::claim);
return builder.build();
}
@Test
@DisplayName("the default validator set does not look at the audience")
void defaultIgnoresAudience() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
Jwt token = jwt(Map.of("aud", List.of("some-other-service"),
"iss", "https://an-issuer-we-never-heard-of.example.com"));
assertThat(validator.validate(token).hasErrors()).isFalse();
}
@Test
@DisplayName("the default validator set does not look at the issuer either")
void defaultIgnoresIssuer() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
assertThat(validator.validate(jwt(Map.of("iss", "https://evil.example.com"))).hasErrors())
.isFalse();
}
@Test
@DisplayName("the default validator set does reject an expired token")
void defaultRejectsExpired() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
Jwt expired = Jwt.withTokenValue("token")
.header("alg", "RS256")
.issuedAt(Instant.now().minusSeconds(7200))
.expiresAt(Instant.now().minusSeconds(3600))
.subject("alice")
.build();
assertThat(validator.validate(expired).hasErrors()).isTrue();
}
@Test
@DisplayName("createDefault() delegates to three validators, none of which is about identity")
void defaultDelegates() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefault();
assertThat(validator).isInstanceOf(DelegatingOAuth2TokenValidator.class);
// Reading the delegate list back rather than trusting the disassembly.
var field = org.springframework.util.ReflectionUtils
.findField(DelegatingOAuth2TokenValidator.class, "tokenValidators");
org.springframework.util.ReflectionUtils.makeAccessible(field);
@SuppressWarnings("unchecked")
var delegates = (java.util.Collection<OAuth2TokenValidator<Jwt>>) org.springframework.util.ReflectionUtils
.getField(field, validator);
// Reading this back is why the article says three rather than two: the disassembly
// of createDefault() shows JwtTimestampValidator and X509CertificateThumbprintValidator
// being constructed, and JwtTypeValidator arrives from createDefaultWithValidators.
assertThat(delegates.stream().map((d) -> d.getClass().getSimpleName()))
.containsExactlyInAnyOrder("JwtTypeValidator", "JwtTimestampValidator",
"X509CertificateThumbprintValidator");
}
@Test
@DisplayName("the at+jwt validator rejects a token whose audience names another service")
void atJwtRejectsWrongAudience() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createAtJwtValidator()
.issuer("http://127.0.0.1:9000")
.audience("downstream-api")
.build();
Jwt token = atJwt(Map.of("aud", List.of("reporting-api"), "iss", "http://127.0.0.1:9000",
"jti", "id", "client_id", "reporting-service"));
assertThat(validator.validate(token).hasErrors()).isTrue();
}
@Test
@DisplayName("the at+jwt validator requires a client_id claim, which Spring Authorization Server does not emit by default")
void atJwtRequiresClientId() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createAtJwtValidator()
.issuer("http://127.0.0.1:9000")
.audience("downstream-api")
.build();
Jwt withoutClientId = jwt(Map.of("aud", List.of("downstream-api"),
"iss", "http://127.0.0.1:9000", "jti", "id"));
assertThat(validator.validate(withoutClientId).hasErrors()).isTrue();
Jwt withClientId = atJwt(Map.of("aud", List.of("downstream-api"),
"iss", "http://127.0.0.1:9000", "jti", "id", "client_id", "edge-service"));
assertThat(validator.validate(withClientId).hasErrors()).isFalse();
}
@Test
@DisplayName("createDefaultWithIssuer adds an issuer check and still no audience check")
void withIssuerStillIgnoresAudience() {
OAuth2TokenValidator<Jwt> validator = JwtValidators
.createDefaultWithIssuer("http://127.0.0.1:9000");
Jwt wrongIssuer = jwt(Map.of("iss", "https://evil.example.com", "aud", List.of("x")));
assertThat(validator.validate(wrongIssuer).hasErrors()).isTrue();
Jwt rightIssuerWrongAudience = jwt(Map.of("iss", "http://127.0.0.1:9000",
"aud", List.of("some-other-service")));
assertThat(validator.validate(rightIssuerWrongAudience).hasErrors()).isFalse();
}
}