101 lines
4.1 KiB
Markdown
101 lines
4.1 KiB
Markdown
# 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)*
|