79406231

Date: 2025-02-02 08:02:19
Score: 0.5
Natty:
Report link

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>
Reasons:
  • Long answer (-1):
  • Has code block (-0.5):
  • User mentioned (1): @dip-m's
  • Self-answer (0.5):
  • Low reputation (0.5):
Posted by: pleasebenice