We have a scenario where our users are linked against a back-end using OAuth2 code grant. We're using oauth2Client() rather than oauth2Login() as this is not for the purpose of SSO and calls are made out-of-band of oauth2 authenticated requests.
As part of this we receive anonymous callbacks from the back-end which we then respond to with the OAuth2 WebClient. We manually load the authorised client as part of this flow ...
val authorizedClient: OAuth2AuthorizedClient? =
oAuth2AuthorizedClientService.loadAuthorizedClient<OAuth2AuthorizedClient>(
clientRegistrationId,
ourPrincipalName
).awaitSingleOrNull()
....
...
return oauthWebClient
.get()
.uri(uriString)
.attributes(oauth2AuthorizedClient(authorizedClient))
.retrieve()
.awaitBody()
This works fine when the access token is valid. When the access token expires refresh_token is correctly called and succeeds. However the new refresh and access token are saved anonymously rather than against the clientRegistrationId, ourPrincipalName key above.
Debugging through the code it appears that this is because the request has no security context. We've a permitAll() rule on the callback path. Which results in the AnonymousAuthorizedClientRepository being used with the anonymous credentials setup in ServerOAuth2AuthorizedClientExchangeFilterFunction.
Is this expected behaviour or is it a bug?
If it's expected behaviour is there a strategy for dealing with this scenario? We could probably manually kludge it by constructing a security context via the filter chain but that doesn't seem like the correct approach.
Comment From: ivanmcshane
FWIW, to get it to work we did the following:
- Construct an
authenticated=trueAuthentication before calling theWebClientwith the correct principal name, secret doesn't matter (and we don't know it anyway). - Set as an attribute consumer
- Use an
ExchangeFilterFunctionthat takes the attribute then applies it to the context when the call is made.
next.exchange(newRequest)
.contextWrite(ReactiveSecurityContextHolder
.withAuthentication(newRequest
.attributes()[PRINCIPAL] as Authentication?))
This works but it seems a bit of a hack that's brittle to spring internal changes so I'd appreciate it if there's a more appropriate way of doing it.
Comment From: jgrandja
@ivanmcshane Thanks for getting in touch, but it feels like this is a question that would be better suited to Stack Overflow. We prefer to use GitHub issues only for bugs and enhancements. Feel free to update this issue with a link to the re-posted question (so that other people can find it) or add a minimal sample that reproduces this issue if you feel this is a genuine bug.
Is this expected behaviour or is it a bug?
This is expected behaviour. Take a look at ServerOAuth2AuthorizedClientExchangeFilterFunction.currentAuthenticationMono as this is used to obtain the current Authentication, which is populated in OAuth2AuthorizeRequest.principal and then passed to ReactiveOAuth2AuthorizedClientManager.authorize().
This is why your solution works.
We have a scenario where our users are linked against a back-end using OAuth2 code grant.
Your flow is not a typical setup. Refreshing an expired access token in a back-channel and without the context of the current user (that owns the access/refresh token) is not standard as per authorization_code grant flow. Because of this, the solution you are using works but may seem odd only because the flow being used is not a standard flow - typically the browser (and current user) is in play during a refresh.