Add the service-to-service module
This commit is contained in:
75
service-to-service/docs/01-the-four-processes.md
Normal file
75
service-to-service/docs/01-the-four-processes.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# 1. The four processes
|
||||
|
||||
*Next: [2. Three ways to get a token for the next hop](02-three-ways-to-get-a-token.md)*
|
||||
|
||||
Everything in this module runs against four real processes, because the questions it answers
|
||||
(“whose identity arrives?”, “what does the receiving service check?”) only have answers when
|
||||
there is a second process to arrive at.
|
||||
|
||||
| Process | Port | What it is |
|
||||
|---|---|---|
|
||||
| `authserver` | 9000 | A real Spring Authorization Server. Mints every token used anywhere here |
|
||||
| `gateway` | 8080 | Spring Cloud Gateway Server MVC, proxying to `edge` |
|
||||
| `edge` | 8081 | Resource server **and** OAuth2 client. The middle hop |
|
||||
| `downstream` | 8082 | Resource server. Reports who it thinks is calling |
|
||||
| `mtls` | 8443 | Separate. Certificate authentication instead of tokens — chapter 6 |
|
||||
|
||||
`./scripts/run.sh` starts the first four in order and waits for each.
|
||||
|
||||
## Getting a user token without a browser
|
||||
|
||||
The "browser flow" is four HTTP requests and a cookie jar, and
|
||||
[`scripts/user-token.sh`](../scripts/user-token.sh) does all of it with `curl`:
|
||||
|
||||
1. `GET /oauth2/authorize?…` unauthenticated → the request is saved, 302 to `/login`
|
||||
2. `GET /login`, scrape the `_csrf` hidden field
|
||||
3. `POST /login` with the credentials and that token → 302 back to the saved request
|
||||
4. `GET` the authorize URL again, now authenticated → 302 to the redirect URI with `?code=`
|
||||
5. `POST /oauth2/token` with the code and the PKCE `code_verifier`
|
||||
|
||||
[`docs/output/01-user-token.txt`](output/01-user-token.txt) is the token that comes out. Doing
|
||||
this once by hand is the fastest way to understand what an OIDC library is doing on your behalf.
|
||||
|
||||
## Four things that cost time while building this
|
||||
|
||||
**`issuer-uri` creates a startup-ordering dependency.** Configuring an OAuth2 *client* with
|
||||
`spring.security.oauth2.client.provider.<id>.issuer-uri` makes `ClientRegistrations` fetch
|
||||
`/.well-known/openid-configuration` during context refresh. If the authorization server is not
|
||||
up, the client will not start:
|
||||
|
||||
```
|
||||
ResourceAccessException: I/O error on GET request for
|
||||
"http://127.0.0.1:9000/.well-known/openid-configuration": Connection refused
|
||||
```
|
||||
|
||||
Nothing in that message says "start order". Naming `token-uri` and `jwk-set-uri` explicitly
|
||||
removes the coupling, which is why `edge.yml` does.
|
||||
|
||||
**`OAuth2AuthorizationServerConfiguration` no longer exists.** Every pre-7.0 Authorization
|
||||
Server tutorial opens with
|
||||
`OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)`. That class, and the whole
|
||||
`org.springframework.security.oauth2.server.authorization.config.annotation.web.*` package tree,
|
||||
is **absent** from `spring-security-oauth2-authorization-server` 7.1.1. The configurer moved into
|
||||
`spring-security-config` at
|
||||
`org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization`,
|
||||
and there is now a DSL method:
|
||||
|
||||
```java
|
||||
http.oauth2AuthorizationServer(Customizer.withDefaults())
|
||||
```
|
||||
|
||||
It is a compile error, not a deprecation warning.
|
||||
|
||||
**A public client is asked for consent.** `ClientAuthenticationMethod.NONE` plus
|
||||
`authorization_code` produced a `Consent required` page mid-flow, which stops any scripted
|
||||
redemption dead. `ClientSettings.builder().requireAuthorizationConsent(false)` is explicit in
|
||||
`AuthServerApplication` for that reason.
|
||||
|
||||
**Spring Cloud is a different release train.** `spring-cloud-dependencies` 2025.1.3 —
|
||||
gateway 5.0.3 — declares `<spring-boot.version>4.0.8</spring-boot.version>` in
|
||||
`spring-cloud-build`. This module runs it under Boot **4.1.1** and it works, but that is a
|
||||
combination nobody tested, and it is the reason a Boot upgrade can be blocked by a gateway.
|
||||
Check `spring-cloud-build`'s POM before assuming the trains are aligned.
|
||||
|
||||
---
|
||||
*Next: [2. Three ways to get a token for the next hop](02-three-ways-to-get-a-token.md)*
|
||||
100
service-to-service/docs/02-three-ways-to-get-a-token.md
Normal file
100
service-to-service/docs/02-three-ways-to-get-a-token.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 2. Three ways to get a token for the next hop
|
||||
|
||||
*Prev: [1. The four processes](01-the-four-processes.md) · Next: [3. RestClient interceptors](03-restclient-interceptors.md)*
|
||||
|
||||
The edge service has a valid token in its hands and needs to call `downstream`. There are three
|
||||
answers, and [`docs/output/02-five-strategies.txt`](output/02-five-strategies.txt) runs all of
|
||||
them against the same request so the difference is a diff.
|
||||
|
||||
| Endpoint | `sub` downstream sees | `scope` downstream sees |
|
||||
|---|---|---|
|
||||
| `/edge/naive` | — (401) | — |
|
||||
| `/edge/relay` | `alice` | `[orders.write, orders.read]` |
|
||||
| `/edge/client-credentials` | `edge-service` | `[orders.read]` |
|
||||
| `/edge/exchange` | `alice` | `[orders.read]` |
|
||||
| `/edge/relay-async` | — (401) | — |
|
||||
|
||||
## Relay
|
||||
|
||||
Forward the token you were given, unchanged. Ten lines, no dependency on the OAuth2 client
|
||||
machinery:
|
||||
|
||||
```java
|
||||
builder.requestInterceptor((request, body, execution) -> {
|
||||
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication instanceof JwtAuthenticationToken token) {
|
||||
request.getHeaders().setBearerAuth(token.getToken().getTokenValue());
|
||||
}
|
||||
return execution.execute(request, body);
|
||||
})
|
||||
```
|
||||
|
||||
Downstream sees `alice`, which is what you want in an audit log. It also sees
|
||||
`orders.write`, which downstream had no business receiving: the token was minted for the
|
||||
request the user made at the edge, and every service it is relayed to gets the union of
|
||||
everything the user consented to. One compromised service holds a credential good against all
|
||||
of them for the rest of its lifetime.
|
||||
|
||||
Relay is right for a hop inside one trust boundary. It is wrong the moment the next hop is
|
||||
operated by someone who should not hold the user's full token.
|
||||
|
||||
## Client credentials
|
||||
|
||||
The service authenticates as itself:
|
||||
|
||||
```yaml
|
||||
spring.security.oauth2.client.registration.edge-service:
|
||||
authorization-grant-type: client_credentials
|
||||
scope: orders.read
|
||||
```
|
||||
|
||||
Downstream now sees `sub: edge-service` and exactly `orders.read`. The scope problem is solved.
|
||||
The user is gone — `downstream`'s log says a service called it, and answering "who ordered
|
||||
this?" requires correlating two logs by a request id you hope somebody propagated.
|
||||
|
||||
## Token exchange (RFC 8693)
|
||||
|
||||
Trade the user's token for one scoped to the next hop, keeping the user:
|
||||
|
||||
```yaml
|
||||
authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange
|
||||
scope: orders.read
|
||||
```
|
||||
|
||||
`sub: alice`, `scope: [orders.read]`. Both properties, from one grant type that has existed
|
||||
since 2020 and that almost nobody reaches for.
|
||||
|
||||
The catch is configuration, not concept. `OAuth2AuthorizedClientProviderBuilder`'s defaults do
|
||||
**not** include token exchange, so you have to add the provider yourself:
|
||||
|
||||
```java
|
||||
OAuth2AuthorizedClientProviderBuilder.builder()
|
||||
.clientCredentials()
|
||||
.refreshToken()
|
||||
.provider(new TokenExchangeOAuth2AuthorizedClientProvider())
|
||||
.build();
|
||||
```
|
||||
|
||||
Leave it out and the manager returns `null` rather than raising anything, and the interceptor
|
||||
sends the request with no `Authorization` header at all. A 401 that looks like a downstream
|
||||
problem and is a wiring problem.
|
||||
|
||||
## The trap: relay from another thread
|
||||
|
||||
`/edge/relay-async` does exactly what `/edge/relay` does, on a virtual thread from an executor.
|
||||
It returns 401.
|
||||
|
||||
`SecurityContextHolder` is a `ThreadLocal`. The interceptor reads it; the task runs on a thread
|
||||
that never had a `SecurityContext` written to it; the `instanceof` fails; the header is never
|
||||
set. Nothing throws. This is the same mechanism as
|
||||
[Spring Security Context Propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/),
|
||||
and the cure is the same: `DelegatingSecurityContextExecutorService`, or
|
||||
`ContextPropagatingTaskDecorator`, or capture the token value on the request thread and pass it
|
||||
as a parameter.
|
||||
|
||||
Any relay built on `SecurityContextHolder` inherits every one of those failure modes. A relay
|
||||
built on an explicit token parameter inherits none of them, at the cost of a parameter on every
|
||||
method between the controller and the client.
|
||||
|
||||
---
|
||||
*Prev: [1. The four processes](01-the-four-processes.md) · Next: [3. RestClient interceptors](03-restclient-interceptors.md)*
|
||||
77
service-to-service/docs/03-restclient-interceptors.md
Normal file
77
service-to-service/docs/03-restclient-interceptors.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 3. `RestClient` interceptors
|
||||
|
||||
*Prev: [2. Three ways to get a token](02-three-ways-to-get-a-token.md) · Next: [4. What a resource server does not validate](04-what-is-not-validated.md)*
|
||||
|
||||
`OAuth2ClientHttpRequestInterceptor`, in
|
||||
`org.springframework.security.oauth2.client.web.client`, is the framework's answer for a
|
||||
`RestClient` that needs a token. Its whole public surface:
|
||||
|
||||
```java
|
||||
public OAuth2ClientHttpRequestInterceptor(OAuth2AuthorizedClientManager manager);
|
||||
public void setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler handler);
|
||||
public void setClientRegistrationIdResolver(ClientRegistrationIdResolver resolver);
|
||||
public void setPrincipalResolver(PrincipalResolver resolver);
|
||||
```
|
||||
|
||||
Wiring it takes one line, and choosing the registration per call takes one more:
|
||||
|
||||
```java
|
||||
RestClient client = builder
|
||||
.baseUrl("http://127.0.0.1:8082")
|
||||
.requestInterceptor(new OAuth2ClientHttpRequestInterceptor(authorizedClientManager))
|
||||
.build();
|
||||
|
||||
client.get().uri("/orders")
|
||||
.attributes(clientRegistrationId("edge-service")) // static import
|
||||
.retrieve().body(Map.class);
|
||||
```
|
||||
|
||||
`clientRegistrationId(..)` is a static method on `RequestAttributeClientRegistrationIdResolver`.
|
||||
Without it, the default resolver finds nothing and the request goes out unauthenticated.
|
||||
|
||||
## Which `OAuth2AuthorizedClientManager`
|
||||
|
||||
This is the choice that decides whether the thing works off a request thread.
|
||||
|
||||
| Manager | Storage | Needs a request? |
|
||||
|---|---|---|
|
||||
| `DefaultOAuth2AuthorizedClientManager` | `OAuth2AuthorizedClientRepository` (session) | **Yes** |
|
||||
| `AuthorizedClientServiceOAuth2AuthorizedClientManager` | `OAuth2AuthorizedClientService` | No |
|
||||
|
||||
For service-to-service calls there is no end user whose authorization is being stored per
|
||||
session, so the second one is right — and it is the one that keeps working from a scheduled
|
||||
task, a message listener or an `@Async` method.
|
||||
|
||||
Getting this wrong produces `ClientAuthorizationRequiredException` or a silent `null` in a
|
||||
context that has no `HttpServletRequest`, which reads like an OAuth problem and is a bean
|
||||
problem.
|
||||
|
||||
## What it caches, and what it does not
|
||||
|
||||
The manager stores the authorized client (access token and, if issued, refresh token) in the
|
||||
`OAuth2AuthorizedClientService` and reuses it until it is within the clock skew of expiry.
|
||||
So a `client_credentials` registration does **not** hit the token endpoint per request. What it
|
||||
does do is re-request on expiry, synchronously, inside whichever call happens to be first —
|
||||
worth knowing when a latency percentile spikes on a period that matches your token lifetime.
|
||||
|
||||
## The hand-rolled relay, and why it is still reasonable
|
||||
|
||||
The relay interceptor in
|
||||
[`DownstreamClients`](../src/main/java/com/ankurm/s2s/edge/DownstreamClients.java) does not use
|
||||
any of the above:
|
||||
|
||||
```java
|
||||
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication instanceof JwtAuthenticationToken token) {
|
||||
request.getHeaders().setBearerAuth(token.getToken().getTokenValue());
|
||||
}
|
||||
```
|
||||
|
||||
That is not a worse version of `OAuth2ClientHttpRequestInterceptor`; it is a different thing.
|
||||
The interceptor **obtains** a token under a client registration. This **forwards** the token
|
||||
already in hand. There is no client registration for "the caller's token", and there should not
|
||||
be. Just do not confuse the two: the hand-rolled one carries the `ThreadLocal` dependency from
|
||||
chapter 2, and the framework one does not.
|
||||
|
||||
---
|
||||
*Prev: [2. Three ways to get a token](02-three-ways-to-get-a-token.md) · Next: [4. What a resource server does not validate](04-what-is-not-validated.md)*
|
||||
125
service-to-service/docs/04-what-is-not-validated.md
Normal file
125
service-to-service/docs/04-what-is-not-validated.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 4. What a resource server does not validate
|
||||
|
||||
*Prev: [3. RestClient interceptors](03-restclient-interceptors.md) · Next: [5. The gateway](05-the-gateway.md)*
|
||||
|
||||
This is the most important chapter in the module.
|
||||
|
||||
## The demonstration
|
||||
|
||||
`reporting-service` is a client registered on the same authorization server, with nothing to do
|
||||
with the downstream service. Its access tokens carry `aud: reporting-api`.
|
||||
|
||||
Send one to the downstream service, whose audience is `downstream-api`:
|
||||
|
||||
```
|
||||
HTTP/1.1 200
|
||||
{ "sub": "reporting-service", "aud": ["reporting-api"], "scope": "[orders.read]", ... }
|
||||
```
|
||||
|
||||
Two hundred. Full transcript:
|
||||
[`docs/output/03-audience-ignored.txt`](output/03-audience-ignored.txt).
|
||||
|
||||
## Why
|
||||
|
||||
`JwtValidators.createDefault()` is a `DelegatingOAuth2TokenValidator` over three validators,
|
||||
read back by reflection in `ValidatorContractTests.defaultDelegates`:
|
||||
|
||||
- `JwtTypeValidator`
|
||||
- `JwtTimestampValidator`
|
||||
- `X509CertificateThumbprintValidator`
|
||||
|
||||
Structure, expiry, and certificate binding. **No issuer. No audience. No scope.** Setting
|
||||
`spring.security.oauth2.resourceserver.jwt.issuer-uri` gets you a `JwtIssuerValidator` on top of
|
||||
that, so the issuer is covered. Nothing anywhere adds an audience check unless you write it.
|
||||
|
||||
The practical consequence: in an estate where several services trust one authorization server —
|
||||
which is the normal shape — **any token from that issuer is accepted by every service in it**.
|
||||
A token a partner integration obtained for the reporting API is a valid credential for the
|
||||
payments API. Scope may or may not save you; `scope: orders.read` did not, above.
|
||||
|
||||
## The fix, and its price
|
||||
|
||||
```java
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
JwtValidators.createDefaultWithIssuer(issuer),
|
||||
new JwtAudienceValidator("downstream-api")));
|
||||
```
|
||||
|
||||
`JwtAudienceValidator` is a public class in 7.1. Two lines, and the demonstration above turns
|
||||
into `The aud claim is not valid`.
|
||||
|
||||
The heavier option is the RFC 9068 profile:
|
||||
|
||||
```java
|
||||
JwtValidators.createAtJwtValidator()
|
||||
.issuer("http://127.0.0.1:9000")
|
||||
.audience("downstream-api")
|
||||
.build();
|
||||
```
|
||||
|
||||
That requires `typ`, `exp`, `sub`, `iat`, `jti`, `iss`, `aud` **and `client_id`** to all be
|
||||
present. It is stricter and it is also where two surprises live.
|
||||
|
||||
## Surprise 1: Spring Authorization Server does not emit an RFC 9068 token
|
||||
|
||||
Out of the box its access tokens carry `typ: JWT` in the header and no `client_id` claim. Point
|
||||
`createAtJwtValidator()` at one and every token is rejected. Both are one line in an
|
||||
`OAuth2TokenCustomizer`:
|
||||
|
||||
```java
|
||||
context.getJwsHeader().type("at+jwt");
|
||||
context.getClaims().claim("client_id", clientId);
|
||||
```
|
||||
|
||||
## Surprise 2: making the token compliant breaks every resource server that was not updated
|
||||
|
||||
`NimbusJwtDecoder`'s default JOSE type verifier accepts `JWT` and an absent `typ`, and nothing
|
||||
else. The moment the authorization server starts typing tokens `at+jwt`, every service using
|
||||
Boot's auto-configured decoder answers:
|
||||
|
||||
```
|
||||
WWW-Authenticate: Bearer error="invalid_token",
|
||||
error_description="An error occurred while attempting to decode the Jwt:
|
||||
the given typ value needs to be one of [JWT]"
|
||||
```
|
||||
|
||||
[`docs/output/05-strict-validation.txt`](output/05-strict-validation.txt) has the downstream
|
||||
service accepting the new tokens and the edge service, one hop away and not updated, rejecting
|
||||
them. The message mentions neither RFC 9068 nor the authorization server.
|
||||
|
||||
The fix on the receiving side:
|
||||
|
||||
```java
|
||||
NimbusJwtDecoder.withJwkSetUri(jwks)
|
||||
.jwtProcessorCustomizer((processor) -> processor.setJWSTypeVerifier(
|
||||
new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"),
|
||||
JOSEObjectType.JWT, null)))
|
||||
.build();
|
||||
```
|
||||
|
||||
Roll that out **before** you change the authorization server, not after.
|
||||
|
||||
## A message worth recognising twice
|
||||
|
||||
Two components produce almost the same sentence and mean opposite things:
|
||||
|
||||
| Source | Message |
|
||||
|---|---|
|
||||
| Nimbus `DefaultJOSEObjectTypeVerifier` | `the given typ value needs to be one of [JWT]` |
|
||||
| Spring `JwtTypeValidator` (at+jwt profile) | `the given typ value needs to be one of [at+jwt, application/at+jwt]` |
|
||||
|
||||
The first means "your authorization server is too modern for this decoder". The second means
|
||||
"your authorization server is not modern enough for this validator".
|
||||
|
||||
## And one thing that *is* on by default
|
||||
|
||||
`X509CertificateThumbprintValidator` is in the default set. If a token carries a `cnf` claim
|
||||
with `x5t#S256` — a certificate-bound access token, RFC 8705 — Spring Security **already**
|
||||
checks it against the client certificate on the TLS connection, with no configuration. That is
|
||||
the strongest available defence against a stolen bearer token, it costs nothing on the resource
|
||||
server, and it needs mTLS to be terminated in the application rather than in a sidecar. Chapter
|
||||
6.
|
||||
|
||||
---
|
||||
*Prev: [3. RestClient interceptors](03-restclient-interceptors.md) · Next: [5. The gateway](05-the-gateway.md)*
|
||||
74
service-to-service/docs/05-the-gateway.md
Normal file
74
service-to-service/docs/05-the-gateway.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 5. The gateway, and what `TokenRelay` actually relays
|
||||
|
||||
*Prev: [4. What is not validated](04-what-is-not-validated.md) · Next: [6. mTLS](06-mtls.md)*
|
||||
|
||||
Spring Cloud Gateway Server MVC ships a filter that sounds like it solves the whole problem:
|
||||
|
||||
```yaml
|
||||
filters:
|
||||
- TokenRelay=
|
||||
```
|
||||
|
||||
It is `TokenRelayFilterFunctions`, with two forms:
|
||||
|
||||
```java
|
||||
public static HandlerFilterFunction<ServerResponse, ServerResponse> tokenRelay();
|
||||
public static HandlerFilterFunction<ServerResponse, ServerResponse> tokenRelay(String clientRegistrationId);
|
||||
```
|
||||
|
||||
The documentation is precise about what it does, and it is not what the name suggests to most
|
||||
readers: it takes the access token **of the currently authenticated user** — the one obtained
|
||||
by `oauth2Login()` — or of the named client registration, and puts it in the `Authorization`
|
||||
header of the proxied request.
|
||||
|
||||
It does not mean "forward the incoming bearer token". There is nothing to forward from if the
|
||||
gateway never performed a login.
|
||||
|
||||
## The A/B
|
||||
|
||||
`gateway.yml` defines two routes to the same destination, differing only in the filter.
|
||||
[`docs/output/04-gateway-token-relay.txt`](output/04-gateway-token-relay.txt):
|
||||
|
||||
```
|
||||
/edge/relay (TokenRelay=) → 200, sub: alice
|
||||
/norelay/x (no TokenRelay) → 200, sub: alice
|
||||
```
|
||||
|
||||
Identical. The token arrived because the gateway proxied the `Authorization` header the caller
|
||||
sent, which it would have done anyway. In this configuration `TokenRelay` contributes nothing.
|
||||
|
||||
Where it earns its place is the other shape: a gateway that terminates a **browser session**
|
||||
with `oauth2Login()`, keeps the tokens server-side, hands the browser nothing but a session
|
||||
cookie, and attaches a token on the way through. That is the backend-for-frontend pattern, and
|
||||
it is a genuinely good answer to "where do I keep the token in a SPA" — because the answer is
|
||||
"not in the SPA".
|
||||
|
||||
## The 401 that has nothing to do with OAuth
|
||||
|
||||
Before `GatewaySecurityConfig` existed, every call through the gateway came back:
|
||||
|
||||
```
|
||||
HTTP/1.1 401
|
||||
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
|
||||
```
|
||||
|
||||
A perfectly valid bearer token, rejected by a gateway that had never been told to expect one.
|
||||
Spring Boot applies a default filter chain — HTTP Basic and form login over every path — to any
|
||||
application on the classpath with Spring Security and no `SecurityFilterChain` bean. A gateway
|
||||
is an application. `WWW-Authenticate: Basic` in front of a token-based estate always means this.
|
||||
|
||||
## Choosing where the gateway sits
|
||||
|
||||
| Shape | Gateway does | Downstream sees |
|
||||
|---|---|---|
|
||||
| Pass-through | Routes; validates nothing | The caller's token; each service validates it |
|
||||
| Edge validation | Validates the token, strips it, adds its own identity | The gateway's identity |
|
||||
| BFF | `oauth2Login()`, holds tokens, `TokenRelay` | The user's token, obtained by the gateway |
|
||||
|
||||
Pass-through is the default and is fine while every service validates properly — chapter 4 is
|
||||
about how often that assumption is wrong. Edge validation is the one that tempts teams into
|
||||
"the gateway checked it, so we can trust the header", which is chapter 6's failure mode wearing
|
||||
a different hat.
|
||||
|
||||
---
|
||||
*Prev: [4. What is not validated](04-what-is-not-validated.md) · Next: [6. mTLS](06-mtls.md)*
|
||||
90
service-to-service/docs/06-mtls.md
Normal file
90
service-to-service/docs/06-mtls.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 6. mTLS: in the mesh or in the application
|
||||
|
||||
*Prev: [5. The gateway](05-the-gateway.md) · Next: [7. Choosing](07-choosing.md)*
|
||||
|
||||
Run `./scripts/certs.sh`, then start `MtlsApplication` on 8443. The certificates it generates
|
||||
are the point: `edge.crt` and `rogue.crt` have **identical subjects** and different issuers.
|
||||
|
||||
```
|
||||
subject=CN = edge-service, OU = payments issuer=CN = Internal Mesh CA
|
||||
subject=CN = edge-service, OU = payments issuer=CN = Some Other CA
|
||||
```
|
||||
|
||||
Identity under mTLS is not the subject. It is the subject plus the fact that a CA in the trust
|
||||
store vouched for it.
|
||||
|
||||
## In-application mTLS
|
||||
|
||||
```yaml
|
||||
server:
|
||||
ssl:
|
||||
bundle: server
|
||||
client-auth: need
|
||||
spring:
|
||||
ssl:
|
||||
bundle:
|
||||
pem:
|
||||
server:
|
||||
keystore: { certificate: "file:...server.crt", private-key: "file:...server.key" }
|
||||
truststore: { certificate: "file:...internal-ca.crt" }
|
||||
```
|
||||
|
||||
```java
|
||||
http.x509((x509) -> x509
|
||||
.subjectPrincipalRegex("CN=([^,]*)(?:,|$)")
|
||||
.userDetailsService(certificateUsers()));
|
||||
```
|
||||
|
||||
A good certificate produces a `PreAuthenticatedAuthenticationToken` with the CN as the
|
||||
principal, and — in Spring Security 7 — a `FACTOR_X509` authority alongside the roles, the
|
||||
sibling of the `FACTOR_BEARER` you get from a JWT and the `FACTOR_PASSWORD` you get from Basic.
|
||||
Any assertion using `containsExactly` on authorities will fail on it.
|
||||
|
||||
## Three things the transcript shows that a diagram does not
|
||||
|
||||
[`docs/output/06-mtls.txt`](output/06-mtls.txt):
|
||||
|
||||
**The rogue certificate produces no HTTP status at all.** `curl` exits 56; there is no response
|
||||
line, no 401, no 403, and nothing in the application log at `INFO`. The handshake failed. Your
|
||||
application-level metrics will show nothing, because from the application's point of view
|
||||
nothing happened. Debugging this means `-Djavax.net.debug=ssl:handshake` or the load balancer's
|
||||
own logs.
|
||||
|
||||
**`client-auth: need` is a property of the connector, not of a path.** `/mtls/trusted-header` is
|
||||
`permitAll()` and it fails exactly the same way without a certificate. You cannot expose a
|
||||
public health endpoint on an mTLS-only connector; it needs a second connector, or `want`
|
||||
instead of `need` plus an explicit authorization rule that treats an absent certificate as
|
||||
anonymous.
|
||||
|
||||
**Header-based identity is verified by nothing.** The last call in the transcript presents a
|
||||
valid certificate for `edge-service` and a header claiming to be `payments-service`, and the
|
||||
endpoint reports `payments-service`.
|
||||
|
||||
## Mesh mTLS
|
||||
|
||||
A service mesh terminates TLS in a sidecar. The application receives plain HTTP on localhost and
|
||||
the peer identity arrives as a header — `X-Forwarded-Client-Cert` in Envoy, carrying the SPIFFE
|
||||
URI SAN. The trade:
|
||||
|
||||
| | Mesh | In-application |
|
||||
|---|---|---|
|
||||
| Certificate lifecycle | Handled, rotated automatically | Yours: issuance, rotation, expiry alerts |
|
||||
| Application code | None | An SSL bundle plus `x509(..)` |
|
||||
| Identity in the app | A header | A verified `X509Certificate` |
|
||||
| Works with cert-bound tokens (RFC 8705) | **No** | Yes |
|
||||
| Failure mode | Anything that bypasses the sidecar can spoof the header | Handshake failure, no HTTP status |
|
||||
|
||||
The row that decides it for a security-sensitive service is the RFC 8705 one. Certificate-bound
|
||||
access tokens — the `cnf` / `x5t#S256` claim, which `X509CertificateThumbprintValidator`
|
||||
already checks by default (chapter 4) — bind a token to the TLS connection it was issued for, so
|
||||
a stolen token is useless without the private key. That binding requires the application to see
|
||||
the client certificate. Terminate mTLS in a sidecar and the strongest defence available against
|
||||
token theft is off the table.
|
||||
|
||||
If you take mesh identity from a header, the header must be **stripped at ingress** on every
|
||||
path into the pod, and the pod must not be reachable except through the proxy. Both are
|
||||
infrastructure guarantees that no amount of application code can verify — which is exactly why
|
||||
`/mtls/trusted-header` answers `"verifiedBy": "nothing. This endpoint believes a header."`.
|
||||
|
||||
---
|
||||
*Prev: [5. The gateway](05-the-gateway.md) · Next: [7. Choosing](07-choosing.md)*
|
||||
53
service-to-service/docs/07-choosing.md
Normal file
53
service-to-service/docs/07-choosing.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 7. Choosing
|
||||
|
||||
*Prev: [6. mTLS](06-mtls.md)*
|
||||
|
||||
## Which propagation strategy
|
||||
|
||||
| Situation | Use |
|
||||
|---|---|
|
||||
| One hop, same team, same trust boundary, audit needs the user | Relay |
|
||||
| A job with no user: scheduler, message listener, reconciliation | Client credentials |
|
||||
| The next hop is another team's service, and audit needs the user | Token exchange |
|
||||
| The next hop is outside your organisation | Client credentials, and a token minted for them |
|
||||
|
||||
The honest default is **relay inside a boundary, exchange across one**. Client credentials is
|
||||
the right answer more often than it is used for work with no user attached, and the wrong answer
|
||||
whenever somebody will later ask "who did this?".
|
||||
|
||||
## Do you need any of this?
|
||||
|
||||
A callout that belongs in every article on this subject and is usually missing:
|
||||
|
||||
> **If your services are a single deployment behind one ingress, all of this is cost with no
|
||||
> benefit.** Two Spring Boot applications in one VPC, called by one another, with no
|
||||
> multi-tenancy and no external partner, do not need an authorization server, a token exchange
|
||||
> grant or a mesh. Network-level isolation plus a shared secret is a defensible design, and it
|
||||
> is a design you can reason about at 3am. The machinery in this repository earns its keep when
|
||||
> there are enough services, enough teams, or enough regulatory pressure that "who called this,
|
||||
> on whose behalf, with what permission" has to be answerable from a log rather than from
|
||||
> memory.
|
||||
|
||||
## A checklist that is short on purpose
|
||||
|
||||
1. Every resource server validates **audience**, not just signature and expiry (chapter 4).
|
||||
2. No relay reads `SecurityContextHolder` from a thread the request did not create (chapter 2).
|
||||
3. Every service has a `SecurityFilterChain` bean, so nothing falls back to Boot's Basic default
|
||||
(chapter 5).
|
||||
4. Client registrations name `token-uri` / `jwk-set-uri` rather than `issuer-uri`, unless you
|
||||
want a startup-ordering dependency (chapter 1).
|
||||
5. If identity arrives in a header, something upstream strips that header from every external
|
||||
request, and you can name the component that does it (chapter 6).
|
||||
6. Token lifetimes are short enough that the audience gap in item 1 is bounded even when
|
||||
somebody forgets.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Spring Security Context Propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/) — why the async relay returns 401
|
||||
- [The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/) — where `BearerTokenAuthenticationFilter` sits
|
||||
- [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) — token exchange
|
||||
- [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) — the `at+jwt` access token profile
|
||||
- [RFC 8705](https://datatracker.ietf.org/doc/html/rfc8705) — mTLS client authentication and certificate-bound access tokens
|
||||
|
||||
---
|
||||
*Prev: [6. mTLS](06-mtls.md)*
|
||||
26
service-to-service/docs/output/01-user-token.txt
Normal file
26
service-to-service/docs/output/01-user-token.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
==============================================================================
|
||||
docs/output/01-user-token.txt
|
||||
A complete authorization_code + PKCE flow, driven by curl. No browser, no OIDC library.
|
||||
scripts/user-token.sh, then scripts/claims.sh
|
||||
==============================================================================
|
||||
|
||||
{
|
||||
"alg": "RS256",
|
||||
"kid": "<uuid>"
|
||||
}
|
||||
{
|
||||
"aud": "downstream-api",
|
||||
"exp": <epoch>,
|
||||
"iat": <epoch>,
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"jti": "<uuid>",
|
||||
"nbf": <epoch>,
|
||||
"scope": [
|
||||
"orders.write",
|
||||
"orders.read"
|
||||
],
|
||||
"sub": "alice"
|
||||
}
|
||||
|
||||
# sub is the human. scope is what the human consented to. aud names the service the
|
||||
# token was minted for - chapter 4 is about whether anybody looks at it.
|
||||
110
service-to-service/docs/output/02-five-strategies.txt
Normal file
110
service-to-service/docs/output/02-five-strategies.txt
Normal file
@@ -0,0 +1,110 @@
|
||||
==============================================================================
|
||||
docs/output/02-five-strategies.txt
|
||||
The same request into the edge service, five ways of getting a token for the hop to
|
||||
downstream. Read the sub and scope of each downstream response.
|
||||
GET /edge/{naive,relay,client-credentials,exchange,relay-async}
|
||||
==============================================================================
|
||||
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/naive
|
||||
{
|
||||
"strategy": "no token forwarded",
|
||||
"error": "Unauthorized: 401 Unauthorized: [no body]"
|
||||
}
|
||||
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/relay
|
||||
{
|
||||
"strategy": "bearer token relayed from the incoming request",
|
||||
"downstream": {
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": false,
|
||||
"sub": "alice",
|
||||
"aud": [
|
||||
"downstream-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.write, orders.read]",
|
||||
"client_id": null,
|
||||
"authorities": [
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
|
||||
"SCOPE_orders.read",
|
||||
"SCOPE_orders.write"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"total": "42.00",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/client-credentials
|
||||
{
|
||||
"strategy": "the edge service's own client_credentials token",
|
||||
"downstream": {
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": false,
|
||||
"sub": "edge-service",
|
||||
"aud": [
|
||||
"downstream-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.read]",
|
||||
"client_id": null,
|
||||
"authorities": [
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
|
||||
"SCOPE_orders.read"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"total": "42.00",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/exchange
|
||||
{
|
||||
"strategy": "RFC 8693 token exchange",
|
||||
"downstream": {
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": false,
|
||||
"sub": "alice",
|
||||
"aud": [
|
||||
"downstream-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.read]",
|
||||
"client_id": null,
|
||||
"authorities": [
|
||||
"SCOPE_orders.read",
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"total": "42.00",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8081/edge/relay-async
|
||||
{
|
||||
"strategy": "relay attempted from a separate thread",
|
||||
"error": "Unauthorized: 401 Unauthorized: [no body]"
|
||||
}
|
||||
|
||||
# naive - 401. The control.
|
||||
# relay - sub: alice, scope: [orders.write, orders.read]. The user's own
|
||||
# token, unchanged, including scopes downstream did not need.
|
||||
# client-creds - sub: edge-service, scope: [orders.read]. Correctly scoped, and
|
||||
# the user has disappeared from downstream's audit log.
|
||||
# exchange - sub: alice, scope: [orders.read]. Both. This is what RFC 8693 is
|
||||
# for and it is the one nobody reaches for.
|
||||
# relay-async - 401. The relay interceptor reads SecurityContextHolder, which is
|
||||
# a ThreadLocal, and the call was made on a different thread.
|
||||
53
service-to-service/docs/output/03-audience-ignored.txt
Normal file
53
service-to-service/docs/output/03-audience-ignored.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
==============================================================================
|
||||
docs/output/03-audience-ignored.txt
|
||||
A token minted for a DIFFERENT service, presented to the downstream service.
|
||||
Default validators.
|
||||
==============================================================================
|
||||
|
||||
# the token reporting-service was issued:
|
||||
{
|
||||
"alg": "RS256",
|
||||
"kid": "<uuid>"
|
||||
}
|
||||
{
|
||||
"aud": "reporting-api",
|
||||
"exp": <epoch>,
|
||||
"iat": <epoch>,
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"jti": "<uuid>",
|
||||
"nbf": <epoch>,
|
||||
"scope": [
|
||||
"orders.read"
|
||||
],
|
||||
"sub": "reporting-service"
|
||||
}
|
||||
|
||||
$ curl -H 'Authorization: Bearer <reporting-service token>' 127.0.0.1:8082/orders
|
||||
{
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": false,
|
||||
"sub": "reporting-service",
|
||||
"aud": [
|
||||
"reporting-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.read]",
|
||||
"client_id": null,
|
||||
"authorities": [
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
|
||||
"SCOPE_orders.read"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"total": "42.00",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# HTTP 200. The aud claim says reporting-api. The service is downstream-api.
|
||||
# JwtValidators.createDefault() is a DelegatingOAuth2TokenValidator over three
|
||||
# validators - JwtTypeValidator, JwtTimestampValidator and
|
||||
# X509CertificateThumbprintValidator. Structure, expiry, and certificate binding.
|
||||
# No issuer. No audience. Read back by reflection in ValidatorContractTests.
|
||||
80
service-to-service/docs/output/04-gateway-token-relay.txt
Normal file
80
service-to-service/docs/output/04-gateway-token-relay.txt
Normal file
@@ -0,0 +1,80 @@
|
||||
==============================================================================
|
||||
docs/output/04-gateway-token-relay.txt
|
||||
Spring Cloud Gateway Server MVC with 'filters: - TokenRelay='.
|
||||
GET /edge/relay through the gateway on 8080.
|
||||
==============================================================================
|
||||
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8080/edge/relay
|
||||
HTTP/1.1 200
|
||||
cache-control: no-cache, no-store, max-age=0, must-revalidate
|
||||
expires: 0
|
||||
pragma: no-cache
|
||||
x-content-type-options: nosniff
|
||||
x-frame-options: DENY
|
||||
x-xss-protection: 0
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"strategy": "bearer token relayed from the incoming request",
|
||||
"downstream": {
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": false,
|
||||
"sub": "alice",
|
||||
"aud": [
|
||||
"downstream-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.write, orders.read]",
|
||||
"client_id": null,
|
||||
"authorities": [
|
||||
"SCOPE_orders.read",
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
|
||||
"SCOPE_orders.write"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"total": "42.00",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
$ curl 127.0.0.1:8080/edge/relay # no Authorization header at all
|
||||
status 401
|
||||
|
||||
# and the identical route with the TokenRelay filter REMOVED:
|
||||
$ curl -H "Authorization: Bearer $TOKEN" 127.0.0.1:8080/norelay/x
|
||||
{
|
||||
"strategy": "bearer token relayed from the incoming request",
|
||||
"downstream": {
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": false,
|
||||
"sub": "alice",
|
||||
"aud": [
|
||||
"downstream-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.write, orders.read]",
|
||||
"client_id": null,
|
||||
"authorities": [
|
||||
"SCOPE_orders.read",
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
|
||||
"SCOPE_orders.write"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"total": "42.00",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# TokenRelay relays the access token of the currently authenticated USER - the one
|
||||
# obtained by oauth2Login(). This gateway has no oauth2Login, so there is no
|
||||
# authorized client to read a token from, and the filter contributes nothing. What
|
||||
# reaches the edge service is whatever Authorization header the caller sent, because
|
||||
# the gateway proxied it. TokenRelay is not 'forward the incoming bearer token'.
|
||||
43
service-to-service/docs/output/05-strict-validation.txt
Normal file
43
service-to-service/docs/output/05-strict-validation.txt
Normal file
@@ -0,0 +1,43 @@
|
||||
==============================================================================
|
||||
docs/output/05-strict-validation.txt
|
||||
The same tokens against JwtValidators.createAtJwtValidator().issuer(..).audience(..),
|
||||
with the authorization server emitting RFC 9068 tokens (typ: at+jwt, client_id claim).
|
||||
STRICT=true ./scripts/run.sh
|
||||
==============================================================================
|
||||
|
||||
# the wrong-audience token that was accepted in 03:
|
||||
HTTP/1.1 401
|
||||
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: The aud claim is not valid", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://127.0.0.1:8082/.well-known/oauth-protected-resource"
|
||||
|
||||
# a token minted for this service:
|
||||
{
|
||||
"service": "downstream:8082",
|
||||
"strictValidation": true,
|
||||
"sub": "edge-service",
|
||||
"aud": [
|
||||
"downstream-api"
|
||||
],
|
||||
"iss": "http://127.0.0.1:9000",
|
||||
"scope": "[orders.read]",
|
||||
"client_id": "edge-service",
|
||||
"authorities": [
|
||||
"FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=<timestamp>]",
|
||||
"SCOPE_orders.read"
|
||||
],
|
||||
"cnf": null,
|
||||
"orders": [
|
||||
{
|
||||
"id": 1,
|
||||
"total": "42.00"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# and the edge service, which was NOT updated - it still uses Boot's
|
||||
# auto-configured decoder:
|
||||
HTTP/1.1 401
|
||||
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the given typ value needs to be one of [JWT]", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://127.0.0.1:8081/.well-known/oauth-protected-resource"
|
||||
|
||||
# Turning on RFC 9068 at the authorization server broke every resource server that
|
||||
# still has NimbusJwtDecoder's default JOSE type verifier, and the error message
|
||||
# mentions neither RFC 9068 nor the authorization server.
|
||||
43
service-to-service/docs/output/06-mtls.txt
Normal file
43
service-to-service/docs/output/06-mtls.txt
Normal file
@@ -0,0 +1,43 @@
|
||||
==============================================================================
|
||||
docs/output/06-mtls.txt
|
||||
Client-certificate authentication on port 8443, server.ssl.client-auth=need.
|
||||
Certificates from scripts/certs.sh. edge.crt and rogue.crt have IDENTICAL subjects and
|
||||
different issuers.
|
||||
==============================================================================
|
||||
|
||||
$ openssl x509 -in target/certs/edge.crt -noout -subject -issuer
|
||||
subject=CN = edge-service, OU = payments
|
||||
issuer=CN = Internal Mesh CA
|
||||
$ openssl x509 -in target/certs/rogue.crt -noout -subject -issuer
|
||||
subject=CN = edge-service, OU = payments
|
||||
issuer=CN = Some Other CA
|
||||
|
||||
$ curl --cert edge.crt --key edge.key https://localhost:8443/mtls/whoami
|
||||
{
|
||||
"principal": "edge-service",
|
||||
"authenticationType": "PreAuthenticatedAuthenticationToken",
|
||||
"authorities": [
|
||||
"ROLE_SERVICE",
|
||||
"FACTOR_X509"
|
||||
],
|
||||
"certificateSubject": "OU=payments,CN=edge-service",
|
||||
"certificateIssuer": "CN=Internal Mesh CA"
|
||||
}
|
||||
|
||||
$ curl --cert rogue.crt --key rogue.key https://localhost:8443/mtls/whoami
|
||||
[curl exit 56, http 000]
|
||||
|
||||
$ curl https://localhost:8443/mtls/trusted-header # a permitAll() endpoint
|
||||
[curl exit 56, http 000]
|
||||
|
||||
$ curl --cert edge.crt --key edge.key -H 'X-Client-Cert-Subject: CN=payments-service' \
|
||||
https://localhost:8443/mtls/trusted-header
|
||||
{"caller":"CN=payments-service","verifiedBy":"nothing. This endpoint believes a header."}
|
||||
|
||||
# Three things worth reading twice.
|
||||
# 1. The rogue certificate fails with curl exit 56 and NO http status. The handshake
|
||||
# is rejected; the application never sees a request and logs nothing at INFO.
|
||||
# 2. So does the permitAll() endpoint. client-auth=need is a property of the
|
||||
# CONNECTOR, not of a path. You cannot expose a public endpoint on that port.
|
||||
# 3. The last call is what a mesh deployment usually looks like from inside the
|
||||
# application: an identity taken from a header, verified by nothing.
|
||||
8
service-to-service/docs/output/07-tests.txt
Normal file
8
service-to-service/docs/output/07-tests.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
==============================================================================
|
||||
docs/output/07-tests.txt
|
||||
mvn -B test
|
||||
==============================================================================
|
||||
|
||||
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.225 s -- in com.ankurm.s2s.ValidatorContractTests
|
||||
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
Reference in New Issue
Block a user