1
0
Files
spring-security-demo/service-to-service/docs/02-three-ways-to-get-a-token.md

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