Add the service-to-service module
This commit is contained in:
17
README.md
17
README.md
@@ -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
|
||||
```
|
||||
|
||||
103
service-to-service/README.md
Normal file
103
service-to-service/README.md
Normal 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
|
||||
75
service-to-service/docs/01-the-four-processes.md
Normal file
75
service-to-service/docs/01-the-four-processes.md
Normal 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)*
|
||||
100
service-to-service/docs/02-three-ways-to-get-a-token.md
Normal file
100
service-to-service/docs/02-three-ways-to-get-a-token.md
Normal 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) · 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) · Next: [3. RestClient interceptors](03-restclient-interceptors.md)*
|
||||
77
service-to-service/docs/03-restclient-interceptors.md
Normal file
77
service-to-service/docs/03-restclient-interceptors.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 3. `RestClient` interceptors
|
||||
|
||||
*Prev: [2. Three ways to get a token](02-three-ways-to-get-a-token.md) · 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) · Next: [4. What a resource server does not validate](04-what-is-not-validated.md)*
|
||||
125
service-to-service/docs/04-what-is-not-validated.md
Normal file
125
service-to-service/docs/04-what-is-not-validated.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 4. What a resource server does not validate
|
||||
|
||||
*Prev: [3. RestClient interceptors](03-restclient-interceptors.md) · 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) · Next: [5. The gateway](05-the-gateway.md)*
|
||||
74
service-to-service/docs/05-the-gateway.md
Normal file
74
service-to-service/docs/05-the-gateway.md
Normal 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) · 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) · Next: [6. mTLS](06-mtls.md)*
|
||||
90
service-to-service/docs/06-mtls.md
Normal file
90
service-to-service/docs/06-mtls.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 6. mTLS: in the mesh or in the application
|
||||
|
||||
*Prev: [5. The gateway](05-the-gateway.md) · 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) · Next: [7. Choosing](07-choosing.md)*
|
||||
53
service-to-service/docs/07-choosing.md
Normal file
53
service-to-service/docs/07-choosing.md
Normal 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)*
|
||||
26
service-to-service/docs/output/01-user-token.txt
Normal file
26
service-to-service/docs/output/01-user-token.txt
Normal 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.
|
||||
110
service-to-service/docs/output/02-five-strategies.txt
Normal file
110
service-to-service/docs/output/02-five-strategies.txt
Normal 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.
|
||||
53
service-to-service/docs/output/03-audience-ignored.txt
Normal file
53
service-to-service/docs/output/03-audience-ignored.txt
Normal 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.
|
||||
80
service-to-service/docs/output/04-gateway-token-relay.txt
Normal file
80
service-to-service/docs/output/04-gateway-token-relay.txt
Normal 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'.
|
||||
43
service-to-service/docs/output/05-strict-validation.txt
Normal file
43
service-to-service/docs/output/05-strict-validation.txt
Normal 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.
|
||||
43
service-to-service/docs/output/06-mtls.txt
Normal file
43
service-to-service/docs/output/06-mtls.txt
Normal 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.
|
||||
8
service-to-service/docs/output/07-tests.txt
Normal file
8
service-to-service/docs/output/07-tests.txt
Normal 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
|
||||
82
service-to-service/pom.xml
Normal file
82
service-to-service/pom.xml
Normal 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>
|
||||
36
service-to-service/scripts/certs.sh
Executable file
36
service-to-service/scripts/certs.sh
Executable 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."
|
||||
12
service-to-service/scripts/claims.sh
Executable file
12
service-to-service/scripts/claims.sh
Executable 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
|
||||
214
service-to-service/scripts/run-all.sh
Executable file
214
service-to-service/scripts/run-all.sh
Executable 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"
|
||||
64
service-to-service/scripts/run.sh
Executable file
64
service-to-service/scripts/run.sh
Executable 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"
|
||||
23
service-to-service/scripts/stop.sh
Executable file
23
service-to-service/scripts/stop.sh
Executable 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
|
||||
48
service-to-service/scripts/user-token.sh
Executable file
48
service-to-service/scripts/user-token.sh
Executable 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"])'
|
||||
@@ -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} — 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} — client_credentials. The edge service's own identity, with no
|
||||
* user anywhere in it.</li>
|
||||
* <li>{@code edge-exchange} — 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();
|
||||
}
|
||||
}
|
||||
@@ -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 — 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());
|
||||
}
|
||||
}
|
||||
@@ -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 — 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;
|
||||
}
|
||||
}
|
||||
@@ -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 — base URL, path, error handling —
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -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 — 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 — 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();
|
||||
}
|
||||
}
|
||||
@@ -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 — or of the named client registration —
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -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 — HTTP Basic and
|
||||
* form login over every path — 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 — 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();
|
||||
}
|
||||
}
|
||||
@@ -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 — not Spring Security — 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 — another pod, a debug
|
||||
* port-forward, a misconfigured NetworkPolicy — 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.");
|
||||
}
|
||||
}
|
||||
8
service-to-service/src/main/resources/authserver.yml
Normal file
8
service-to-service/src/main/resources/authserver.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
server:
|
||||
port: 9000
|
||||
spring:
|
||||
application:
|
||||
name: authserver
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security.oauth2.server.authorization: ${AS_LOG_LEVEL:INFO}
|
||||
17
service-to-service/src/main/resources/downstream.yml
Normal file
17
service-to-service/src/main/resources/downstream.yml
Normal 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}
|
||||
39
service-to-service/src/main/resources/edge.yml
Normal file
39
service-to-service/src/main/resources/edge.yml
Normal 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}
|
||||
32
service-to-service/src/main/resources/gateway.yml
Normal file
32
service-to-service/src/main/resources/gateway.yml
Normal 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}
|
||||
26
service-to-service/src/main/resources/mtls.yml
Normal file
26
service-to-service/src/main/resources/mtls.yml
Normal 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}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user