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>