I have an app that connects to the Gmail API of its users. It authenticates by using the refreshToken to get an accessToken.
Libraries in use: Spring boot 3.4.1 -> Spring security 6.4.2
The refresh token is stored in the database and can be used for a long time, but is invalidated if password is changed for example. We need a way to get a new refresh token easily from an integration page, but cannot see how this can be done programatically with Spring security in a simple way.
Right now I have manually found the refreshToken by doing the following:
.oauth2Login(Customizer.withDefaults())
to the SecurityFilterChainclass: DefaultAuthorizationCodeTokenResponseClient
method: public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {
In my properties file I have:
spring.security.oauth2.client.registration.google.client-id={....}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=/,.profile,.email
spring.security.oauth2.client.provider.google.authorization-uri=;access_type=offline
So the question is simply Is there any Spring security features that can simplify this process of getting the refreshToken?
Ideal flow:
private fun refreshAccessToken(refreshToken: String): Credential {
val credential = GoogleCredential.Builder().setTransport(httpTransport)
.setJsonFactory(JSON_FACTORY)
.setClientSecrets(CLIENT_ID, CLIENT_SECRET)
.build()
credential.refreshToken = refreshToken
credential.refreshToken()
return credential
}
I have an app that connects to the Gmail API of its users. It authenticates by using the refreshToken to get an accessToken.
Libraries in use: Spring boot 3.4.1 -> Spring security 6.4.2
The refresh token is stored in the database and can be used for a long time, but is invalidated if password is changed for example. We need a way to get a new refresh token easily from an integration page, but cannot see how this can be done programatically with Spring security in a simple way.
Right now I have manually found the refreshToken by doing the following:
.oauth2Login(Customizer.withDefaults())
to the SecurityFilterChainclass: DefaultAuthorizationCodeTokenResponseClient
method: public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {
In my properties file I have:
spring.security.oauth2.client.registration.google.client-id={....}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=https://mail.google.com/,https://www.googleapis.com/auth/userinfo.profile,https://www.googleapis.com/auth/userinfo.email
spring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth?prompt=consent&access_type=offline
So the question is simply Is there any Spring security features that can simplify this process of getting the refreshToken?
Ideal flow:
private fun refreshAccessToken(refreshToken: String): Credential {
val credential = GoogleCredential.Builder().setTransport(httpTransport)
.setJsonFactory(JSON_FACTORY)
.setClientSecrets(CLIENT_ID, CLIENT_SECRET)
.build()
credential.refreshToken = refreshToken
credential.refreshToken()
return credential
}
Set up in Google Cloud Console:
When a user needs to authenticate:
private fun buildOAuthUrl(): String {
val encodedRedirectUri = URLEncoder.encode(redirectUri, StandardCharsets.UTF_8.toString())
val encodedScope = scope.split(" ")
.map { URLEncoder.encode(it.trim(), StandardCharsets.UTF_8.toString()) }
.joinToString(" ")
return UriComponentsBuilder.fromUriString(oauthUrl)
.queryParam("client_id", clientId)
.queryParam("redirect_uri", encodedRedirectUri)
.queryParam("response_type", "code")
.queryParam("scope", encodedScope)
.queryParam("access_type", "offline")
.queryParam("prompt", "consent")
.build()
.toUriString()
}
After getting auth code:
val response = GoogleAuthorizationCodeTokenRequest(
httpTransport,
jsonFactory,
clientId,
clientSecret,
authCode(We will get from the callback URL),
redirectUri
).setGrantType("authorization_code").execute()
val refreshToken = response.refreshToken
We are getting refresh token from the response.refreshToken
After We are update our DB table with new generated refresh token with current user email
This is the full code that works below.
Note in the frontend the first communication is to /generate-token
which builds the Oauth url where the user logs in.
Once the user has logged in there is a callback to /oauth2/callback/google
with the authCode as paramter that is used to get the refreshToken.
Using @dip-m's answer.
@Controller
class GmailController(
private val securityUtils: SecurityUtils,
private val googleOAuthService: GoogleOAuthService
) {
@GetMapping("/settings")
fun getSettingsPage(model: Model): String {
if (!model.containsAttribute("tokenGrantSuccess")) {
model.addAttribute("tokenGrantSuccess", model.getAttribute("tokenGrantSuccess"))
} else {
model.addAttribute("tokenGrantFail", false)
}
return "setting"
}
@GetMapping("/oauth2/callback/google")
fun refreshTokenCallBack(
redirectAttributes: RedirectAttributes,
@RequestParam("code") authCode: String,
): String {
val currentUserEmail = securityUtils.getCurrentUser()?.email
?: throw OAuthException("User not authenticated")
googleOAuthService.handleOAuthCallback(authCode, currentUserEmail)
redirectAttributes.addFlashAttribute("tokenGrantSuccess", true)
return "redirect:/settings"
}
}
@Service
class GoogleOAuthService(
private val jdbcClient: JdbcClient,
@Value("\${spring.security.oauth2.client.registration.google.client-id}") private val clientId: String,
@Value("\${spring.security.oauth2.client.registration.google.client-secret}") private val clientSecret: String,
@Value("\${spring.security.oauth2.client.registration.google.redirect-uri}") private val redirectUri: String,
@Value("\${spring.security.oauth2.oauthUrl}") private val oauthUrl: String,
@Value("\${spring.security.oauth2.client.registration.google.scope}") private val scope: String
) {
private val logger = LoggerFactory.getLogger(GoogleOAuthService::class.java)
private val jsonFactory: JsonFactory = GsonFactory.getDefaultInstance()
private val httpTransport: NetHttpTransport = GoogleNetHttpTransport.newTrustedTransport()
fun handleOAuthCallback(authCode: String, userEmail: String) {
try {
val response = GoogleAuthorizationCodeTokenRequest(
httpTransport,
jsonFactory,
clientId,
clientSecret,
authCode,
redirectUri
).setGrantType("authorization_code").execute()
updateUserRefreshToken(userEmail, response.refreshToken)
} catch (e: Exception) {
logger.error("Failed to handle OAuth callback", e)
throw OAuthException("Failed to process OAuth callback: ${e.message}")
}
}
private fun updateUserRefreshToken(email: String, refreshToken: String) {
jdbcClient.sql("""
UPDATE users SET refresh_token = :refreshToken WHERE email = :email
""".trimIndent())
.param("refreshToken", refreshToken)
.param("email", email)
.update()
logger.info("User refresh token successfully updated for email $email")
}
fun generateOAuthUrl(): String {
validateOAuthConfig()
return try {
buildOAuthUrl()
} catch (e: Exception) {
logger.error("Failed to generate OAuth URL", e)
throw OAuthException("Failed to generate OAuth URL: ${e.message}")
}
}
private fun buildOAuthUrl(): String {
val encodedRedirectUri = URLEncoder.encode(redirectUri, StandardCharsets.UTF_8.toString())
val encodedScope = URLEncoder.encode(scope.replace(",", " "), StandardCharsets.UTF_8.toString())
return UriComponentsBuilder.fromUriString(oauthUrl)
.queryParam("client_id", clientId)
.queryParam("redirect_uri", encodedRedirectUri)
.queryParam("response_type", "code")
.queryParam("scope", encodedScope)
.queryParam("access_type", "offline")
.queryParam("prompt", "consent")
.build()
.toUriString()
}
private fun validateOAuthConfig() {
val missingFields = buildList {
if (clientId.isBlank()) add("clientId")
if (redirectUri.isBlank()) add("redirectUri")
if (oauthUrl.isBlank()) add("oauthUrl")
if (scope.isBlank()) add("scope")
}
if (missingFields.isNotEmpty()) {
val errorMessage = "Missing required fields: ${missingFields.joinToString(", ")}"
logger.error(errorMessage)
throw OAuthException(errorMessage)
}
}
}
class OAuthException(message: String) : RuntimeException(message)
@RestController
class GmailRestController(private val googleOAuthService: GoogleOAuthService) {
@GetMapping("/generate-token")
fun generateRefreshToken(): String = googleOAuthService.generateOAuthUrl()
}
Frontend code, ScriptsStylingAndMeta
just imports HTMX, Shoelace and Bootstrap:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:hx-on="http://www.w3.org/1999/xhtml">
<head>
<div th:replace="~{fragments :: ScriptsStylingAndMeta}"/>
<title>Settings Page</title>
<style>
.settings-card {
background-color: var(--sl-color-neutral-0);
border-radius: var(--sl-border-radius-medium);
box-shadow: var(--sl-shadow-medium);
padding: var(--sl-spacing-large);
}
.settings-description {
color: var(--sl-color-neutral-600);
margin-bottom: var(--sl-spacing-medium);
}
.success-message {
display: flex;
align-items: center;
gap: var(--sl-spacing-small);
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div th:replace="~{fragments :: header}"/>
<div class="container py-5">
<sl-card class="settings-card">
<div class="settings-section">
<sl-header class="mb-4">
<h2>Google Integration</h2>
</sl-header>
<div id="integration-status" th:fragment="status" class="stack">
<div th:if="${tokenGrantSuccess}" class="success-message" role="status">
<sl-alert variant="success" open>
<sl-icon slot="icon" name="check2-circle"></sl-icon>
Refresh token has been successfully configured!
</sl-alert>
</div>
<div th:unless="${tokenGrantSuccess}">
<p class="settings-description">
Generate a refresh token to enable Gmail integration with your account.
</p>
<sl-button
variant="primary"
size="large"
hx-get="/generate-token"
hx-trigger="click"
hx-swap="none"
hx-indicator="this"
hx-on::after-request="handleTokenResponse(event)"
aria-label="Generate Gmail refresh token"
>
<sl-icon slot="prefix" name="key"></sl-icon>
Generate Refresh Token
</sl-button>
<sl-alert
variant="danger"
class="error-message hidden"
id="error-message"
>
<sl-icon slot="icon" name="exclamation-triangle"></sl-icon>
Failed to generate token. Please try again.
</sl-alert>
</div>
</div>
</div>
</sl-card>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Ensure Shoelace components are defined
customElements.whenDefined('sl-alert').then(() => {
const errorAlert = document.getElementById('error-message');
function handleTokenResponse(event) {
const response = event.detail.xhr.response;
if (response) {
window.location.href = response;
} else {
console.error('Invalid response received');
errorAlert.classList.remove('hidden');
setTimeout(() => {
errorAlert.classList.add('hidden');
}, 5000);
}
}
window.handleTokenResponse = handleTokenResponse;
});
});
</script>
</body>
</html>
DefaultAuthorizationCodeTokenResponseClient
is deprecated since 6.4 – Roar S. Commented Jan 29 at 15:16