This article will explain how to login with Google Sign-In on frontend, get an authorization code and pass it to a Spring Security backend. After backend verify with Google, pass JWT to frontend. In the process, compare the email obtained from Google Sign-In with the account read from JPA.
The complete code can be found in .
Table of Contents
Problems with OAuth Login
Spring Security’s OAuth Login allows us to login through Google Sign-In. The flow of OAuth Login is that backend sends an Authorization Code request to OAuth server. Then, OAuth server redirects frontend to login screen. After frontend login, OAuth server sends an authorization code to backend. Finally, backend sends a request to OAuth server to verify the authorization code, client ID, and client secret. After OAuth server authenticates successfully, it returns an ID token and access token to backend. If you are not familiar with the above process, you can read the following article first.
It can be seen from the above that OAuth Login is suitable for web frontend but not for mobile phone frontend. At least, redirection is not so intuitive on mobile phone.
OAuth2 + JWT-Token-Based Authentication
This article will introduce a method that combines OAuth Login and JWT-Token-Based modes. The general flow will be as described in Google Sign-In for server-side apps , as shown in the figure below.
Frontend sends an authorization request to Google API Server, and redirects to OAuth 2.0 login screen. After users login, return id_token and access_token to the front end. Frontend sends id_token and access_token to backend. Backend will use id_token and access_token to verify with Google API Server. If the verification succeeds, frontend will be notified that the login is successful, and the entire flow is roughly completed.
The difference from the above is that we will ask Google API Server for authorization code on frontend, and send it to the backend. Backend will send authorization code, client ID, and client secret to Google API Server for verification. After success, the received OAuth information is made into JWT and sent to frontend. After that, frontend uses JWT for authentication, the same as the JWT-Token-Based Authentication described in the following article.
Why do we use authorization code instead of id_token and access_token? This is because Spring Security’s OAuth Login uses Authorization Code flow. So using the same method as it, we can borrow authorization flow from OAuth Login, so we can save a lot of code. If we use id_token and access_token, the verification process must be rewritten.
OAuth2 + JWT-Token-Based Authentication Flow
Before starting to program, let’s first understand the flow we want to implement, as shown in the following figure:
If you are not familiar with SecurityFilterChain, ExceptionTranslationFilter, FilterSecurityInterceptor in the figure, please read the following article first.
The idea is to add two filters to SecurityFilterChain, namely RestOAuth2AuthorizationFilter and RestOAuth2AuthenticationFilter. When logging in, RestOAuth2AuthenticationFilter returns a JWT to frontend. After that, frontend’s requests will carry this JWT, and RestOAuth2AuthorizationFilter will be responsible for verifying this JWT.
OAuth2 + JWT-Token-Based Authentication Architecture
The following figure shows how we modify OAuth Login architecture to our architecture. The red part is what we will add, and the rest are borrowed from OAuth Login. If we used id_token and access_token, the orange part would be reimplemented as well.
OAuth2 + JWT-Token-Based Authentication Project
In this project, after verifying through Google Sign-In, the obtained user email is used as an account. Then read user account from JPA and compare them. If it exists, compare them. If it does not exist, create a new account.
Creating Google OAuth Credentials
First, we need to obtain a pair of client ID and client secret, so let’s create credentials on Google Console. The following article explains the detailed steps.
However, there is no need to fill in any URI on Authorized redirect URIs, as shown below.
Creating Project
Create a Spring Boot project and include the dependencies in the figure.
If you don’t know how to create a project, or don’t understand the picture above, you can refer to the following article first.
Because we will also use Jackson to parse JSON strings and use JJWT to generate JWT, we will also include the following dependencies.
dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("io.jsonwebtoken:jjwt-api:0.11.2") implementation("io.jsonwebtoken:jjwt-impl:0.11.2") implementation("io.jsonwebtoken:jjwt-jackson:0.11.2") }
Set Client DI & Client Secret
Put the obtained client ID and client secret into application.properties.
spring.security.oauth2.client.registration.google.client-id=63245096424-6nn16rjn23h8p11g8bkp2aogl4kqneuc.apps.googleusercontent.com spring.security.oauth2.client.registration.google.client-secret=JP-lvQxE8eo359zgQMKpvC5k
login.html
Add login.html, the code is as follows:
<html> <head> <meta charset="UTF-8"> <title>Title</title> <script src="https://unpkg.com/axios/dist/axios.min.js"></script> <script src="https://apis.google.com/js/client:platform.js?onload=renderButton" async defer></script> <script> function renderButton() { gapi.load('auth2', function () { auth2 = gapi.auth2.init({ client_id: '63245096424-6nn16rjn23h8p11g8bkp2aogl4kqneuc.apps.googleusercontent.com', scope: 'profile email', }); }); } </script> </head> <body> <button id="signInButton" onclick="onClickSignIn()">Sign in with Google</button> <button onclick="onClickAdmin()">Admin Endpoint</button> <button onclick="onClickUser()">User Endpoint</button> <script> let token; function onSuccess(result) { console.log(result); authenticate(result.code) .then(res => { token = res.data.token; console.log(res); }) .catch(console.log); } function authenticate(code) { return axios.post('http://localhost:8080/auth/google', JSON.stringify({code})); } function callEndpoint(uri, token) { return axios.get(`http://localhost:8080/${uri}`, {headers: {Authorization: `Bearer ${token}`}}); } function onFailure(error) { console.log(error); } function onClickSignIn() { auth2.grantOfflineAccess() .then(onSuccess) .catch(onFailure); } function onClickAdmin() { callEndpoint('admin', token) .then(console.log) .catch(console.log) } function onClickUser() { callEndpoint('user', token) .then(console.log) .catch(console.log) } </script> </body> </html>
login.html displays three buttons, namely Sign In with Google, Admin Endpoint, and User Endpoint. Users first click Sign In with Google to do OAuth login. After success, Google returns an authorization code and pass it to backend, which in turns to complete login flow with it. After logging in, users can click Admin Endpoint or User Endpoint to get user information. The difference is that Admin Endpoint requires Admin permissions.
RestOAuth2AuthenticationFilter
After obtaining an authorization code, users will request POST /auth/google of backend and carry the authorization code to login. RestOAuth2authenticationFilter will process this request. The process is very similar to OAuth2LoginAuthenticationFilter in OAuth Login. It is recommended to read the following article first.
Add RestOAuth2authenticationFilter, its code is as follows:
@Component class RestOAuth2AuthenticationFilter( private val clientRegistrationRepository: ClientRegistrationRepository, private val authorizedClientRepository: OAuth2AuthorizedClientRepository, private val tokenManager: TokenManager, private val userDetailsService: MemberUserDetailsService, private val memberRepository: MemberRepository ) : GenericFilterBean() { companion object { private const val contentTypeHeader = "Content-Type" private const val baseUri = "/auth" private const val registrationIdUriVariableName = "registrationId" private const val redirectUri = "postmessage" private const val nonceParameterName = "nonce" } private val authenticationManager: AuthenticationManager private val authorizationRequestResolver = DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, baseUri) private val requestMatcher = AntPathRequestMatcher("$baseUri/{$registrationIdUriVariableName}", HttpMethod.POST.name) init { val accessTokenResponseClient = DefaultAuthorizationCodeTokenResponseClient() val userService = OidcUserService() val authenticationProvider = OidcAuthorizationCodeAuthenticationProvider(accessTokenResponseClient, userService) authenticationManager = ProviderManager(authenticationProvider) authorizationRequestResolver.setAuthorizationRequestCustomizer { it.redirectUri(redirectUri) it.additionalParameters { additionalParameters -> additionalParameters.remove(nonceParameterName) } it.attributes { attributes -> attributes.remove(nonceParameterName) } } } override fun doFilter(req: ServletRequest, res: ServletResponse, chain: FilterChain) { val request = req as HttpServletRequest val response = res as HttpServletResponse if (!requireAuthentication(request)) { chain.doFilter(request, response) return } try { val authentication = authenticate(request, response) successfulAuthentication(response, authentication) } catch (e: Exception) { unsuccessfulAuthentication(response, e) } } private fun requireAuthentication(request: HttpServletRequest) = requestMatcher.matches(request) private fun authenticate(request: HttpServletRequest, response: HttpServletResponse): OAuth2AuthenticationToken { val code = readCode(request) ?: throw OAuth2AuthenticationException(OAuth2Error("authentication_code_missing")) val registrationId = requestMatcher.matcher(request).variables[registrationIdUriVariableName] ?: throw OAuth2AuthenticationException(OAuth2Error("client_registration_not_found")) val clientRegistration = clientRegistrationRepository.findByRegistrationId(registrationId) ?: throw OAuth2AuthenticationException(OAuth2Error("client_registration_not_found")) val authorizationRequest = authorizationRequestResolver.resolve(request, registrationId) val authorizationResponse = OAuth2AuthorizationResponse .success(code) .redirectUri(redirectUri) .state(authorizationRequest.state) .build() val authenticationRequest = OAuth2LoginAuthenticationToken( clientRegistration, OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse)) val authenticationResult = authenticationManager.authenticate(authenticationRequest) as OAuth2LoginAuthenticationToken val username = authenticationResult.principal.attributes["email"] as String val user = loadUser(username) ?: createUser(authenticationResult) val authorities = mergeAuthorities(authenticationResult, user) val oauth2Authentication = OAuth2AuthenticationToken( authenticationResult.principal, authorities, authenticationResult.clientRegistration.registrationId) val authorizedClient = OAuth2AuthorizedClient( authenticationResult.clientRegistration, oauth2Authentication.name, authenticationResult.accessToken, authenticationResult.refreshToken) authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response) return oauth2Authentication } private fun readCode(request: HttpServletRequest): String? { val authRequest: Map<String, Any> = jacksonObjectMapper().readValue(request.reader) return authRequest["code"] as? String } private fun loadUser(username: String): UserDetails? { return try { userDetailsService.loadUserByUsername(username) } catch (e: Exception) { null } } private fun createUser(authentication: OAuth2LoginAuthenticationToken): UserDetails { val attributes = authentication.principal.attributes val username = attributes["email"] as String val member = Member( username, authentication.clientRegistration.registrationId, attributes["name"] as String, listOf("ROLE_USER") ) memberRepository.save(member) return userDetailsService.loadUserByUsername(username) } private fun mergeAuthorities(authentication: OAuth2LoginAuthenticationToken, user: UserDetails): Collection<GrantedAuthority> { val authorities = HashSet<GrantedAuthority>() authorities.addAll(authentication.authorities) authorities.addAll(user.authorities) return authorities } private fun successfulAuthentication(response: HttpServletResponse, authentication: OAuth2AuthenticationToken) { SecurityContextHolder.getContext().authentication = authentication val token = tokenManager.generate(authentication) tokenManager[token] = authentication response.addHeader(contentTypeHeader, MediaType.APPLICATION_JSON_VALUE) response.writer.println(jacksonObjectMapper().writeValueAsString(TokenResponse(token))) } private fun unsuccessfulAuthentication(response: HttpServletResponse, exception: Exception) { SecurityContextHolder.clearContext() response.sendError(HttpServletResponse.SC_UNAUTHORIZED, exception.message) } private data class TokenResponse(val token: String) }
doFilter()
first check whether the requested URI is /auth/{registrationId}, if not, it will skip to the next filter. In login.html, it sends a login request to POST /auth/google, so here the registrationId will be google. The logic here is based on OAuth Login.
Next, get an authorization code, registrationId, and ClientRegistration. Then, generate OAuth2LoginAuthenticationToken and call authenticationManager.authenticate() for verification.
In authenticationManager, we have added OidcAuthorizationCodeAuthenticationProvider, because Google Sign-In uses Oidc protocol. If you are using OAuth 2.0, such as GitHub, you can add OAuth2AuthorizationCodeAuthenticationProvider.
After the verification is passed, we obtain user’s email. Compare with the account in JPA, if it does not exist, we will create an account.
Finally, an OAuth2AuthenticationToken is generated, which contains user’s basic data and permissions. successfulAuthentication() will put this Authentication object into SecurityContextHolder. Then, generate a JWT, put it and Authentication into a cache. Finally, the JWT is sent back to frontend.
RestOAuth2AuthorizationFilter
After frontend obtains JWT, when requesting any REST APIs, the HTTP request must carry the JWT in its header, as follows:
Authorization: Bearer <JWT>
RestOAuth2AuthorizationFilter will intercept every request and check whether there is a JWT. The code is as follows:
@Component class RestOAuth2AuthorizationFilter(private val tokenManager: TokenManager) : GenericFilterBean() { companion object { private const val authenticationHeader = "Authorization" private const val authenticationScheme = "Bearer" } override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { val token = extractToken(request as HttpServletRequest) if (token != null && StringUtils.hasText(token)) { val authentication = tokenManager[token] if (authentication != null) { SecurityContextHolder.getContext().authentication = authentication } } chain.doFilter(request, response) } private fun extractToken(request: HttpServletRequest): String? { val bearerToken = request.getHeader(authenticationHeader) if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("$authenticationScheme ")) { return bearerToken.substring(authenticationScheme.length + 1) } return null } }
If it detects that there is a JWT, it will take out the Authentication object from the cache and put it in SecurityContextHolder, which means that the authentication is successful!
Creating Database
Add Member Entity, include username
, registrationId
, and authorities
.
@Entity data class Member( @Column(unique = true) val username: String, val registrationId: String, val name: String, @ElementCollection val authorities: Collection<String>, @Id @GeneratedValue var id: Long? = null )
Added MemberRepository to read database.
interface MemberRepository : JpaRepository<Member, Long> { @Query("SELECT m FROM Member m JOIN FETCH m.authorities WHERE m.username = (:username)") fun findByUsername(username: String): Member? }
For the ease of later testing, let’s create a few members in advance in database. Note that only Monika has ADMIN authority.
@Configuration class MemberConfiguration { @Bean fun initMembers(memberRepository: MemberRepository) = ApplicationRunner { memberRepository.saveAll(listOf( Member("monika@gmail.com", "google", "Monika", listOf("ROLE_ADMIN", "ROLE_USER")), Member("jack@gmail.com", "google", "Jack", listOf("ROLE_USER")), Member("peter@gmail.com", "google", "Peter", listOf("ROLE_USER")) )) } }
Added MemberUserDetailsService because RestOAuth2AuthenticationFilter will use it to read user data.
@Component class MemberUserDetailsService(private val memberRepository: MemberRepository) : UserDetailsService { override fun loadUserByUsername(username: String): UserDetails { val member = memberRepository.findByUsername(username) ?: throw UsernameNotFoundException("$username was not found") val authority = member.authorities.map { SimpleGrantedAuthority(it) } return User(member.username, "", authority) } }
TokenManager
Added TokenManager to manage JWT and Authentication objects. Moreover, it also contains methods for generating and verifying JWT.
@Component class TokenManager(cacheManager: CacheManager) { companion object { private const val claimAuthorities = "authorities" private const val claimName = "name" private const val claimEmail = "email" private const val secret = "qsbWaaBHBN/I7FYOrev4yQFJm60sgZkWIEDlGtsRl7El/k+DbUmg8nmWiVvEfhZ91Y67Sc6Ifobi05b/XDwBy4kXUcKTitNqocy7rQ9Z3kMipYjbL3WZUJU2luigIRxhTVNw8FXdT5q56VfY0LcQv3mEp6iFm1JG43WyvGFV3hCkhLPBJV0TWnEi69CfqbUMAIjmymhGjcbqEK8Wt10bbfxkM5uar3tpyqzp3Q==" private val key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)) } private val cache = cacheManager.getCache("tokenManager")!! operator fun get(token: String): OAuth2AuthenticationToken? { return if (validate(token)) { val authentication = cache.get(token)?.get() authentication as OAuth2AuthenticationToken } else { cache.evict(token) null } } operator fun set(token: String, authentication: OAuth2AuthenticationToken) { cache.put(token, authentication) } fun generate(authentication: OAuth2AuthenticationToken): String { val subject = authentication.name val name = authentication.principal.attributes["name"] val email = authentication.principal.attributes["email"] val authorities = authentication.authorities?.joinToString { it.authority } ?: "" val expiration = Date(System.currentTimeMillis() + (60 * 60 * 1000)) return Jwts.builder() .setSubject(subject) .claim(claimAuthorities, authorities) .claim(claimName, name) .claim(claimEmail, email) .signWith(key, SignatureAlgorithm.HS512) .setExpiration(expiration) .compact() } private fun validate(token: String): Boolean { return try { val jwtParser = Jwts.parserBuilder().setSigningKey(key).build() jwtParser.parse(token) true } catch (e: Exception) { false } } }
In addition, use the following code to start the cache.
@Configuration @EnableCaching class CacheConfig { }
MemberController
Add MemberController, the code is as follows:
@RestController class MemberController(private val authorizedClientRepository: OAuth2AuthorizedClientRepository) { @GetMapping("/user") @PreAuthorize("hasRole('ROLE_USER')") fun user(principal: Principal): UserResponse { val authentication = principal as OAuth2AuthenticationToken return UserResponse( authentication.principal.attributes["email"] as String, authentication.principal.attributes["name"] as String ) } data class UserResponse(val email: String, val name: String) @GetMapping("/admin") @PreAuthorize("hasRole('ROLE_ADMIN')") fun admin(principal: Principal, request: HttpServletRequest): AdminResponse { val authentication = principal as OAuth2AuthenticationToken val authorizedClient = authorizedClientRepository.loadAuthorizedClient<OAuth2AuthorizedClient>( authentication.authorizedClientRegistrationId, authentication, request) return AdminResponse( authentication.principal.attributes["email"] as String, authentication.principal.attributes["name"] as String, authorizedClient.accessToken.tokenValue, authorizedClient.refreshToken?.tokenValue ?: "" ) } data class AdminResponse(val email: String, val name: String, val accessToken: String, val refreshToken: String) }
MemberController provides 2 Endpoints, of which admin() requires ROLE_ADMIN permission, that is, only Monika can successfully initiate.
Configuring Spring Security
Finally, it is time to set up Spring Security. Add SecurityConfiguration, its code is as follows:
@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) class SecurityConfig( private val restfulOAuth2AuthorizationFilter: RestOAuth2AuthorizationFilter, private val restfulOAuth2AuthenticationFilter: RestOAuth2AuthenticationFilter ) : WebSecurityConfigurerAdapter() { override fun configure(http: HttpSecurity) { http .csrf().disable() .addFilterBefore(restfulOAuth2AuthorizationFilter, BasicAuthenticationFilter::class.java) .addFilterBefore(restfulOAuth2AuthenticationFilter, BasicAuthenticationFilter::class.java) .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .exceptionHandling() .authenticationEntryPoint(RestOAuth2AuthenticationEntryPoint()) .accessDeniedHandler(RestOAuth2AccessDeniedHandler()) .and() .authorizeRequests() .antMatchers("/login.html").permitAll() .anyRequest().authenticated() } }
We insert RestOAuth2AuthorizationFilter and RestOAuth2AuthenticationFilter into SecurityFilterChain. Also, setup AuthenticationEntryPoint and AccessDeniedHandler.
Testing
After executing the project, browse to http://localhost:8080/login.html. First click Sign In with Google to login. After it succeeds, it will automatically login to backend. After logging in successfully, click User Endpoint to test whether it can be accessed successfully. Finally, click Admin Endpoint. If you don’t have ROLE_ADMIN permission, you should be denied access.
Springdoc
If you want to integrate Springdoc, it will be the same way as it is used in the following article. Set the obtained JWT to Springdoc. For any requests made afterwards, Springdoc will automatically carry the JWT for you.
Conclusion
Spring Security’s OAuth Login already provides a good implementation of OAuth 2.0, but it is not so friendly to mobile apps. Because it uses cookies and does not use RESTful APIs. Of course, we can also not borrow the code from OAuth Login at all, and implement the part of authentication with Google Sign-In ourselves. But in this case, we have to implement each OAuth 2.0 Server, which is quite troublesome.
6 comments
Hi Wayne,
The popup google login is not working in browser incognito mode. It is able to get the authorisation code but after throwing error saying “error: popup closed by user”. If we use the spring boot’s .oauth2Login() dsl, if we send the request to `/oauth2/authorization/google` url, it will be redirected to google authentication page in new tab and once login successful, it will be closed automatically. Can we make the similar flow in this example? If yes what are all changes it will involve?
Thanks
Hi Sai,
I think “error: popup closed by user” means the popup Google SignIn window was closed by the browser with incognito mode automatically.
Hello Wayne,
Could you extend this topic to microservices world? several security patterns ex: gateway security patterns etc. it would be very helpful.
Thank you
Hi Sai,
Sorry I don’t have time to extend this topic to microservices. I don’t even have time to write any article for a while. But, I think you can let every microservice validate the JWT token with the same key as you described before.
Hi Wayne,
Thanks for the great blog. I tried to convert this example with reactive security but unfortunately didn’t make it.
I’m able to login to google but failing with following error log. source code here https://github.com/maradanasai/custom-gateway-social-login-demo kindly help
2022-03-26 18:38:16.330 DEBUG 36800 — [ parallel-5] athPatternParserServerWebExchangeMatcher : Checking match of request : ‘/login.html’; against ‘/login.html’
2022-03-26 18:38:16.330 DEBUG 36800 — [ parallel-5] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : matched
2022-03-26 18:38:16.330 DEBUG 36800 — [ parallel-5] a.DelegatingReactiveAuthorizationManager : Checking authorization on ‘/login.html’ using org.springframework.security.config.web.server.ServerHttpSecurity$AuthorizeExchangeSpec$Access$$Lambda$925/0x000000080085f440@6a2f02e0
2022-03-26 18:38:16.330 DEBUG 36800 — [ parallel-5] o.s.s.w.s.a.AuthorizationWebFilter : Authorization successful
2022-03-26 18:38:16.706 DEBUG 36800 — [ parallel-6] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern=’/logout’, method=POST}
2022-03-26 18:38:16.707 DEBUG 36800 — [ parallel-6] athPatternParserServerWebExchangeMatcher : Request ‘GET /favicon.ico’ doesn’t match ‘POST /logout’
2022-03-26 18:38:16.707 DEBUG 36800 — [ parallel-6] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : No matches found
2022-03-26 18:38:16.707 DEBUG 36800 — [ parallel-6] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : Trying to match using PathMatcherServerWebExchangeMatcher{pattern=’/login.html’, method=null}
2022-03-26 18:38:16.707 DEBUG 36800 — [ parallel-6] athPatternParserServerWebExchangeMatcher : Request ‘GET /favicon.ico’ doesn’t match ‘null /login.html’
2022-03-26 18:38:16.707 DEBUG 36800 — [ parallel-6] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : No matches found
2022-03-26 18:38:16.707 DEBUG 36800 — [ parallel-6] a.DelegatingReactiveAuthorizationManager : Checking authorization on ‘/favicon.ico’ using org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager@6c02f9c8
2022-03-26 18:38:16.707 DEBUG 36800 — [ parallel-6] o.s.s.w.s.a.AuthorizationWebFilter : Authorization failed: Access Denied
Hi Sai,
You will need to add ‘/favicon.ico’ in SecurityConfig.kt in the example source code.
.antMatchers("/login.html", "/favicon.ico")