1
0

Add the service-to-service module

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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