In my previous post we saw how easy it is to protect your application with Google Login.
Now let us see what are all the components responsible for this to work.
The Login process
Request to access the protected Endpoint and Google Authentication process starts
Prerequisites
-
application.yml
is configured with client and provider values - Provider name in the property
spring.security.oauth2.client.registration.provider
is set to google
When access to http://localhost:8080/me
is requested, If you have only one identity provider configured then, Spring redirects you automatically to http://localhost:8080/oauth2/authorization/google
. OAuth2AuthorizationRequestRedirectFilter
which is registered to the url pattern /oauth2/authorization/*
will load the respective configuration and redirect to the Identity Provider. In our case Google.
If you want your users to choose between multiple providers, then configure your
application.yml
for multiple providers, but you need a login page where you can have multiple links for the users to choose from.
On Successful authentication google redirects to the app's redirect url
Once the user authenticates with Google successfully, Google now redirects to the app's redirect url configured in Google's developer console. In our example we chose to have a particular url http://localhost:8080/login/oauth2/code/google
. This is because the Authentication Processing filter for OAuth2 OAuth2LoginAuthenticationFilter
is registered to listen to /login/oauth2/code/*
.
OAuth2LoginAuthenticationFilter delegates authentication to OidcAuthorizationCodeAuthenticationProvider
which does 3 things:
- Exchanges Code for token
- Validates id_token
- Populates User Info by calling the User Info endpoint, from Google's well known configuration
Now you might ask what if I have registered a different redirect URI, and want OAuth2LoginAuthenticationFilter to listen to this. It is pretty simple all you need to do is have the following Security Configuration
@Configuration
@EnableWebSecurity
class SecurityConfiguration: WebSecurityConfigurerAdapter() {
override fun configure(http: HttpSecurity) {
http
.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.oauth2Login()
.redirectionEndpoint()
.baseUri("/oauth/callback/*")
}
}
What Next
As a result of successful authentication, you will get an Authentication object of type OAuth2AuthenticationToken
. This token will contain all the necessary information from id_token
and the user info endpoint
.
You can access all data about the logged in user by
SecurityContextHolder.getContext().authentication as OAuth2AuthenticationToken
or
@GetMapping("/me")
fun hello(currentUser: OAuth2AuthenticationToken): ResponseEntity<OAuth2AuthenticationToken> {
return ResponseEntity.ok(currentUser)
}
But what about Access and Refresh Tokens?
As a result of successful OpenID Connect flow, a client application receives three tokens, access_token, refresh_token and id_token
. We might want to use this access token to access some protected resource from a resource server like tasks API of google. The OAuth2AuthorizedClientService
keeps track of the tokens associated with the user.
val currentUser = SecurityContextHolder.getContext().authentication as OAuth2AuthenticationToken
val currentUserClientConfig = oAuth2AuthorizedClientService.loadAuthorizedClient(
authorizedClientRegistrationId,
currentUser.name)
println("AccessToken: ${currentUserClientConfig.accessToken.tokenValue}")
println("RefreshToken: ${currentUserClientConfig.refreshToken.tokenValue}")
But Access Tokens can expire
When access tokens expire, the resource server like like tasks API of google will return 401 HTTP status, the simplest solution is to throw an OAuth2AuthorizationException
which is a type of AuthenticationException
that will trigger the login flow again.
But we can also use Refresh Tokens to automatically refresh our tokens, by customizing RestTemplate
with a request interceptor that will refresh the tokens on expiry
class BearerTokenInterceptor(private val oAuth2AuthorizedClientService: OAuth2AuthorizedClientService) : ClientHttpRequestInterceptor {
companion object {
val log: Logger = LoggerFactory.getLogger(BearerTokenInterceptor::class.java)
}
private var accessTokenExpiresSkew = Duration.ofMinutes(1)
private val clock = Clock.systemUTC()
override fun intercept(request: HttpRequest, body: ByteArray, execution: ClientHttpRequestExecution): ClientHttpResponse {
val currentUser = SecurityContextHolder.getContext().authentication as OAuth2AuthenticationToken
val currentUserClientConfig = currentUser.clientConfig()
if (isExpired(accessToken = currentUserClientConfig.accessToken)) {
log.info("AccessToken expired, refreshing automatically")
refreshToken(currentUserClientConfig, currentUser)
}
request.headers[AUTHORIZATION] = "Bearer ${currentUserClientConfig.accessToken.tokenValue}"
return execution.execute(request, body)
}
private fun OAuth2AuthenticationToken.clientConfig(): OAuth2AuthorizedClient {
return oAuth2AuthorizedClientService.loadAuthorizedClient(
authorizedClientRegistrationId,
name) ?: throw CredentialsExpiredException("could not load client config for $name, reauthenticate")
}
private fun refreshToken(currentClient: OAuth2AuthorizedClient, currentUser: OAuth2AuthenticationToken) {
val atr = refreshTokenClient(currentClient)
if (atr == null || atr.accessToken == null) {
log.info("Failed to refresh token for ${currentUser.name}")
return
}
val refreshToken = atr.refreshToken ?: currentClient.refreshToken
val updatedClient = OAuth2AuthorizedClient(
currentClient.clientRegistration,
currentClient.principalName,
atr.accessToken,
refreshToken
)
oAuth2AuthorizedClientService.saveAuthorizedClient(updatedClient, currentUser)
}
private fun refreshTokenClient(currentClient: OAuth2AuthorizedClient): OAuth2AccessTokenResponse? {
val formParameters = LinkedMultiValueMap<String, String>()
formParameters.add(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.REFRESH_TOKEN.value)
formParameters.add(OAuth2ParameterNames.REFRESH_TOKEN, currentClient.refreshToken?.tokenValue)
formParameters.add(OAuth2ParameterNames.REDIRECT_URI, currentClient.clientRegistration.redirectUriTemplate)
val requestEntity = RequestEntity
.post(URI.create(currentClient.clientRegistration.providerDetails.tokenUri))
.header(CONTENT_TYPE, APPLICATION_FORM_URLENCODED_VALUE)
.body(formParameters)
return try {
val r = restTemplate(currentClient.clientRegistration.clientId, currentClient.clientRegistration.clientSecret)
val responseEntity = r.exchange(requestEntity, OAuth2AccessTokenResponse::class.java)
responseEntity.body
} catch (e: OAuth2AuthorizationException) {
log.error("Unable to refresh token ${e.error.errorCode}")
throw OAuth2AuthenticationException(e.error, e)
}
}
private fun isExpired(accessToken: OAuth2AccessToken): Boolean {
val now = this.clock.instant()
val expiresAt = accessToken.expiresAt ?: return false
return now.isAfter(expiresAt.minus(this.accessTokenExpiresSkew))
}
private fun restTemplate(clientId: String, clientSecret: String): RestTemplate {
return RestTemplateBuilder()
.additionalMessageConverters(
FormHttpMessageConverter(),
OAuth2AccessTokenResponseHttpMessageConverter())
.errorHandler(OAuth2ErrorResponseErrorHandler())
.basicAuthentication(clientId, clientSecret)
.build()
}
}
So far I have not found that the oauth2-client can automatically refresh tokens within the user session, Let me know if this is the case :)
Conclusion
I tried to put together all pieces involved, Please give me feedback if I missed something :)
Top comments (9)
Hi Shyamala,
Thanks a lot for the detailed description and working code.
Could you please add implementation of Resource Server (which accepts the access_token and responds with protected_resource) as per oauth.com/oauth2-servers/token-int... (Resource server introspects the access_token and caches it for future request processing)
Hi Thank you for the feedback. Its a good idea for my next blog post :) in this series
Thanks for your post. I was also surprised how little code that was necessary in order to do authentication and fetch user information from my provider's userinfo endpoint, but there is one crucial thing I cannot understand. How do I protect my application using such rules:
ROLE_USER should be added by default, but Spring security is responding with 403/forbidden if I go to my protected page /user/index.html after successful authentication. I guess there is something I have misunderstood.
Here's part of my security config:
I have tried to map the roles by using a userAuthoritiesMapper, but it doesn't help much. If I try to write out the authorities after authentication, this is what I get (and notice the ROLE_USER which is actually present):
Hi,
Thank you for the response, I hope you already went through this.
The distinction between
Role
andAuthority
is subtle as explained here, without looking into your userAuthoritiesMapper, I cannot be sure. It would be helpful if you can share your code , for me to have a look at it.Thanks for your reply and your willingness to help. Actually it works after I changed
.antMatchers("/user/", "/user/index.html")
to.antMatchers("/user/**")
.Since our old SAML2.0-based application needs a UserDetails object, do you have any suggestions how to proceed? My thoughts is to configure the following to map authorities and to return a UserDetails object which implements
OAuth2UserService<OidcUserRequest, OidcUser>
.The latter question is just meant as a contribution to the discussion, not something I need help to implement. Using a UserDetails object is very usual in Spring Security, but the documentation does not mention this strategy.
Hi, Thanks for the post. I have took a filter approach to refresh the token. Can you please have a look here:
stackoverflow.com/questions/591441...
This is great, thanks! I would love know how to set up a flow for an API client to access to OpenID where the API client doesn't redirect to a login page.
Hi Rori, Do you mean you need server-server authentication, Where you do not need user token, If so then you have to use the OAuth2 Token Endpoint with
grant_type=client credentials
. If your identity provider supports that.Hi Shyamala,
Do you know how to do this for webflux security, since the below methods are not there.
.redirectionEndpoint()
.baseUri("/oauth/callback/*")