diff --git a/README.md b/README.md index 654834d..e997325 100644 --- a/README.md +++ b/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 ``` diff --git a/service-to-service/README.md b/service-to-service/README.md new file mode 100644 index 0000000..07b6890 --- /dev/null +++ b/service-to-service/README.md @@ -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 diff --git a/service-to-service/docs/01-the-four-processes.md b/service-to-service/docs/01-the-four-processes.md new file mode 100644 index 0000000..b61f06b --- /dev/null +++ b/service-to-service/docs/01-the-four-processes.md @@ -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..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 `4.0.8` 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)* diff --git a/service-to-service/docs/02-three-ways-to-get-a-token.md b/service-to-service/docs/02-three-ways-to-get-a-token.md new file mode 100644 index 0000000..b107f89 --- /dev/null +++ b/service-to-service/docs/02-three-ways-to-get-a-token.md @@ -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)* diff --git a/service-to-service/docs/03-restclient-interceptors.md b/service-to-service/docs/03-restclient-interceptors.md new file mode 100644 index 0000000..6de86c2 --- /dev/null +++ b/service-to-service/docs/03-restclient-interceptors.md @@ -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)* diff --git a/service-to-service/docs/04-what-is-not-validated.md b/service-to-service/docs/04-what-is-not-validated.md new file mode 100644 index 0000000..4a0ce23 --- /dev/null +++ b/service-to-service/docs/04-what-is-not-validated.md @@ -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)* diff --git a/service-to-service/docs/05-the-gateway.md b/service-to-service/docs/05-the-gateway.md new file mode 100644 index 0000000..1c2c7fa --- /dev/null +++ b/service-to-service/docs/05-the-gateway.md @@ -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 tokenRelay(); +public static HandlerFilterFunction 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)* diff --git a/service-to-service/docs/06-mtls.md b/service-to-service/docs/06-mtls.md new file mode 100644 index 0000000..4f19649 --- /dev/null +++ b/service-to-service/docs/06-mtls.md @@ -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)* diff --git a/service-to-service/docs/07-choosing.md b/service-to-service/docs/07-choosing.md new file mode 100644 index 0000000..1f3dbf2 --- /dev/null +++ b/service-to-service/docs/07-choosing.md @@ -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)* diff --git a/service-to-service/docs/output/01-user-token.txt b/service-to-service/docs/output/01-user-token.txt new file mode 100644 index 0000000..dd53d7b --- /dev/null +++ b/service-to-service/docs/output/01-user-token.txt @@ -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": "" +} +{ + "aud": "downstream-api", + "exp": , + "iat": , + "iss": "http://127.0.0.1:9000", + "jti": "", + "nbf": , + "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. diff --git a/service-to-service/docs/output/02-five-strategies.txt b/service-to-service/docs/output/02-five-strategies.txt new file mode 100644 index 0000000..2dc52ec --- /dev/null +++ b/service-to-service/docs/output/02-five-strategies.txt @@ -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=]", + "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=]", + "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=]" + ], + "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. diff --git a/service-to-service/docs/output/03-audience-ignored.txt b/service-to-service/docs/output/03-audience-ignored.txt new file mode 100644 index 0000000..0e35fb6 --- /dev/null +++ b/service-to-service/docs/output/03-audience-ignored.txt @@ -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": "" +} +{ + "aud": "reporting-api", + "exp": , + "iat": , + "iss": "http://127.0.0.1:9000", + "jti": "", + "nbf": , + "scope": [ + "orders.read" + ], + "sub": "reporting-service" +} + +$ curl -H 'Authorization: Bearer ' 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=]", + "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. diff --git a/service-to-service/docs/output/04-gateway-token-relay.txt b/service-to-service/docs/output/04-gateway-token-relay.txt new file mode 100644 index 0000000..a7d46f3 --- /dev/null +++ b/service-to-service/docs/output/04-gateway-token-relay.txt @@ -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=]", + "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=]", + "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'. diff --git a/service-to-service/docs/output/05-strict-validation.txt b/service-to-service/docs/output/05-strict-validation.txt new file mode 100644 index 0000000..99f5da6 --- /dev/null +++ b/service-to-service/docs/output/05-strict-validation.txt @@ -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=]", + "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. diff --git a/service-to-service/docs/output/06-mtls.txt b/service-to-service/docs/output/06-mtls.txt new file mode 100644 index 0000000..cacb987 --- /dev/null +++ b/service-to-service/docs/output/06-mtls.txt @@ -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. diff --git a/service-to-service/docs/output/07-tests.txt b/service-to-service/docs/output/07-tests.txt new file mode 100644 index 0000000..415707c --- /dev/null +++ b/service-to-service/docs/output/07-tests.txt @@ -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 diff --git a/service-to-service/pom.xml b/service-to-service/pom.xml new file mode 100644 index 0000000..49972f0 --- /dev/null +++ b/service-to-service/pom.xml @@ -0,0 +1,82 @@ + + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + service-to-service + 1.0 + jar + + + 25 + UTF-8 + 2025.1.3 + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-oauth2-authorization-server + + + org.springframework.cloud + spring-cloud-starter-gateway-server-webmvc + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/service-to-service/scripts/certs.sh b/service-to-service/scripts/certs.sh new file mode 100755 index 0000000..b98545a --- /dev/null +++ b/service-to-service/scripts/certs.sh @@ -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 + 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 [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." diff --git a/service-to-service/scripts/claims.sh b/service-to-service/scripts/claims.sh new file mode 100755 index 0000000..dd85f62 --- /dev/null +++ b/service-to-service/scripts/claims.sh @@ -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 diff --git a/service-to-service/scripts/run-all.sh b/service-to-service/scripts/run-all.sh new file mode 100755 index 0000000..f3462cd --- /dev/null +++ b/service-to-service/scripts/run-all.sh @@ -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:]+)?//g' \ + -e 's/"(exp|iat|nbf)": [0-9]+/"\1": /g' \ + -e 's/"(jti|kid)": "[0-9a-f-]+"/"\1": ""/g' \ + -e 's/(JSESSIONID=)[0-9A-F]+/\1/g' \ + -e 's/issuedAt=[^]]*\]/issuedAt=]/g' \ + -e 's/ [0-9]+ --- / --- /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 + 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 ' 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" diff --git a/service-to-service/scripts/run.sh b/service-to-service/scripts/run.sh new file mode 100755 index 0000000..3000864 --- /dev/null +++ b/service-to-service/scripts/run.sh @@ -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 + 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" diff --git a/service-to-service/scripts/stop.sh b/service-to-service/scripts/stop.sh new file mode 100755 index 0000000..f95804e --- /dev/null +++ b/service-to-service/scripts/stop.sh @@ -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 diff --git a/service-to-service/scripts/user-token.sh b/service-to-service/scripts/user-token.sh new file mode 100755 index 0000000..ad6a32a --- /dev/null +++ b/service-to-service/scripts/user-token.sh @@ -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"])' diff --git a/service-to-service/src/main/java/com/ankurm/s2s/authserver/AuthServerApplication.java b/service-to-service/src/main/java/com/ankurm/s2s/authserver/AuthServerApplication.java new file mode 100644 index 0000000..eb199a4 --- /dev/null +++ b/service-to-service/src/main/java/com/ankurm/s2s/authserver/AuthServerApplication.java @@ -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. + * + *

Three clients are registered, one per pattern the article covers: + *

    + *
  • {@code spa-client} — authorization_code + PKCE. This is where the user's + * token comes from. {@code scripts/user-token.sh} drives the whole browser flow with curl.
  • + *
  • {@code edge-service} — client_credentials. The edge service's own identity, with no + * user anywhere in it.
  • + *
  • {@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.
  • + *
+ * + *

Tokens carry an {@code aud} claim naming {@code downstream-api}. Chapter 4 is about what + * happens to that claim by default, which is nothing. + * + *

See docs/01-the-four-processes.md. + */ +@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 not. 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 absent 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 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 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(); + } +} diff --git a/service-to-service/src/main/java/com/ankurm/s2s/downstream/DownstreamApplication.java b/service-to-service/src/main/java/com/ankurm/s2s/downstream/DownstreamApplication.java new file mode 100644 index 0000000..7cefef9 --- /dev/null +++ b/service-to-service/src/main/java/com/ankurm/s2s/downstream/DownstreamApplication.java @@ -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 who it thinks is calling. + * + *

{@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. + * + *

Run with {@code -DSTRICT=true} to install the audience and issuer validation that the + * defaults leave out — see + * docs/04-what-is-not-validated.md. + */ +@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. + * + *

{@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 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 orders(Authentication authentication) { + Map 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 whoami(Authentication authentication) { + return Map.of("principal", authentication.getName(), "type", + authentication.getClass().getSimpleName()); + } +} diff --git a/service-to-service/src/main/java/com/ankurm/s2s/edge/ClientManagerConfig.java b/service-to-service/src/main/java/com/ankurm/s2s/edge/ClientManagerConfig.java new file mode 100644 index 0000000..dc0d693 --- /dev/null +++ b/service-to-service/src/main/java/com/ankurm/s2s/edge/ClientManagerConfig.java @@ -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. + * + *

Two details matter and both are easy to get wrong. + * + *

Which manager. {@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. + * + *

Which providers. 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; + } +} diff --git a/service-to-service/src/main/java/com/ankurm/s2s/edge/DownstreamClients.java b/service-to-service/src/main/java/com/ankurm/s2s/edge/DownstreamClients.java new file mode 100644 index 0000000..74ef060 --- /dev/null +++ b/service-to-service/src/main/java/com/ankurm/s2s/edge/DownstreamClients.java @@ -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. + * + *

See docs/03-restclient-interceptors.md. + */ +@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); + } +} diff --git a/service-to-service/src/main/java/com/ankurm/s2s/edge/EdgeApplication.java b/service-to-service/src/main/java/com/ankurm/s2s/edge/EdgeApplication.java new file mode 100644 index 0000000..073bd58 --- /dev/null +++ b/service-to-service/src/main/java/com/ankurm/s2s/edge/EdgeApplication.java @@ -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. + * + * + * + * + * + * + * + * + * + *
Endpoints
EndpointStrategy
{@code /edge/naive}No propagation at all — the control
{@code /edge/relay}Relay the incoming bearer token
{@code /edge/client-credentials}The service's own token
{@code /edge/exchange}RFC 8693 token exchange
{@code /edge/relay-async}Relay from a different thread — the trap
+ * + *

See docs/02-three-ways-to-get-a-token.md. + */ +@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 naive() { + return wrap("no token forwarded", this.clients::plain); + } + + @GetMapping("/edge/relay") + Map relay() { + return wrap("bearer token relayed from the incoming request", this.clients::relayed); + } + + @GetMapping("/edge/client-credentials") + Map clientCredentials() { + return wrap("the edge service's own client_credentials token", this.clients::clientCredentials); + } + + @GetMapping("/edge/exchange") + Map 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 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 wrap(String strategy, Callable call) { + Map 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(); + } +} diff --git a/service-to-service/src/main/java/com/ankurm/s2s/gateway/GatewayApplication.java b/service-to-service/src/main/java/com/ankurm/s2s/gateway/GatewayApplication.java new file mode 100644 index 0000000..7b66a0f --- /dev/null +++ b/service-to-service/src/main/java/com/ankurm/s2s/gateway/GatewayApplication.java @@ -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}. + * + *

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. + * + *

Read docs/05-the-gateway.md 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); + } +} diff --git a/service-to-service/src/main/java/com/ankurm/s2s/gateway/GatewaySecurityConfig.java b/service-to-service/src/main/java/com/ankurm/s2s/gateway/GatewaySecurityConfig.java new file mode 100644 index 0000000..cae4e99 --- /dev/null +++ b/service-to-service/src/main/java/com/ankurm/s2s/gateway/GatewaySecurityConfig.java @@ -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. + * + *

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. + * + *

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 + * docs/05-the-gateway.md. + */ +@Configuration +public class GatewaySecurityConfig { + + @Bean + SecurityFilterChain chain(HttpSecurity http) throws Exception { + return http + .authorizeHttpRequests((auth) -> auth.anyRequest().permitAll()) + .csrf((csrf) -> csrf.disable()) + .build(); + } +} diff --git a/service-to-service/src/main/java/com/ankurm/s2s/mtls/MtlsApplication.java b/service-to-service/src/main/java/com/ankurm/s2s/mtls/MtlsApplication.java new file mode 100644 index 0000000..8f04ba7 --- /dev/null +++ b/service-to-service/src/main/java/com/ankurm/s2s/mtls/MtlsApplication.java @@ -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. + * + *

Run {@code ./scripts/certs.sh} first. Then: + * + *

+ * curl --cacert target/certs/internal-ca.crt \
+ *      --cert target/certs/edge.crt --key target/certs/edge.key \
+ *      https://localhost:8443/mtls/whoami
+ * 
+ * + *

{@code rogue.crt} carries an identical subject and a different issuer, and the TLS + * handshake — not Spring Security — is what rejects it. + * + *

{@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 docs/06-mtls.md. + */ +@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 whoami(Authentication authentication, HttpServletRequest request) { + Map 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 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."); + } +} diff --git a/service-to-service/src/main/resources/authserver.yml b/service-to-service/src/main/resources/authserver.yml new file mode 100644 index 0000000..65decd8 --- /dev/null +++ b/service-to-service/src/main/resources/authserver.yml @@ -0,0 +1,8 @@ +server: + port: 9000 +spring: + application: + name: authserver +logging: + level: + org.springframework.security.oauth2.server.authorization: ${AS_LOG_LEVEL:INFO} diff --git a/service-to-service/src/main/resources/downstream.yml b/service-to-service/src/main/resources/downstream.yml new file mode 100644 index 0000000..b9ca9f4 --- /dev/null +++ b/service-to-service/src/main/resources/downstream.yml @@ -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} diff --git a/service-to-service/src/main/resources/edge.yml b/service-to-service/src/main/resources/edge.yml new file mode 100644 index 0000000..6ee9902 --- /dev/null +++ b/service-to-service/src/main/resources/edge.yml @@ -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} diff --git a/service-to-service/src/main/resources/gateway.yml b/service-to-service/src/main/resources/gateway.yml new file mode 100644 index 0000000..1530f08 --- /dev/null +++ b/service-to-service/src/main/resources/gateway.yml @@ -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} diff --git a/service-to-service/src/main/resources/mtls.yml b/service-to-service/src/main/resources/mtls.yml new file mode 100644 index 0000000..daac0aa --- /dev/null +++ b/service-to-service/src/main/resources/mtls.yml @@ -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} diff --git a/service-to-service/src/test/java/com/ankurm/s2s/ValidatorContractTests.java b/service-to-service/src/test/java/com/ankurm/s2s/ValidatorContractTests.java new file mode 100644 index 0000000..1ebfc4f --- /dev/null +++ b/service-to-service/src/test/java/com/ankurm/s2s/ValidatorContractTests.java @@ -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. + * + *

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 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 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 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 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 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 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 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>) 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 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 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 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(); + } +}