4.1 KiB
2. Three ways to get a token for the next hop
Prev: 1. The four processes · Next: 3. RestClient interceptors
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 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:
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:
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:
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:
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,
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 · Next: 3. RestClient interceptors