From b38ac8b81c0266a75ec316aa76ad198ecd0d8f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Istv=C3=A1n?= Date: Wed, 8 Jul 2026 21:48:28 +0200 Subject: [PATCH 1/3] Set max size for generated QR codes --- .../hu/bme/sch/cmsch/component/app/ApplicationApiController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/app/ApplicationApiController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/app/ApplicationApiController.kt index 415b9e0d..b2fe24a5 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/app/ApplicationApiController.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/app/ApplicationApiController.kt @@ -120,7 +120,7 @@ class ApplicationApiController( @GetMapping("/app/render-qr") fun renderQr(response: HttpServletResponse, @RequestParam text: String?, @RequestParam size: Int = 300) { - if (text.isNullOrBlank() || size < 20) { + if (text.isNullOrBlank() || size < 20 || size > 2048) { response.status = HttpStatus.BAD_REQUEST.value() return } From 5a30a057c9734d015b1f1955c5baa553e0dd6cd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Istv=C3=A1n?= Date: Wed, 8 Jul 2026 21:56:05 +0200 Subject: [PATCH 2/3] Add option to disable merging of account by email and require email confirmation before login --- .../cmsch/component/login/LoginComponent.kt | 13 +++++++++++++ .../component/login/LoginRejectedException.kt | 7 +++++++ .../sch/cmsch/component/login/LoginService.kt | 19 ++++++++++++++++++- .../keycloak/KeycloakUserInfoResponse.kt | 3 +++ .../hu/bme/sch/cmsch/config/SecurityConfig.kt | 12 ++++++++++++ .../controller/admin/EntrypointController.kt | 15 +++++++++------ 6 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginRejectedException.kt diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginComponent.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginComponent.kt index 1e0332b5..ce449876 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginComponent.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginComponent.kt @@ -72,6 +72,19 @@ class LoginComponent( /// ------------------------------------------------------------------------------------------------------------------- + val ssoSecurityGroup by SettingGroup(fieldName = "SSO biztonság", + description = "Google és Keycloak belépés biztonsági beállításai") + + var rejectUnverifiedEmail by BooleanSettingRef(true, serverSideOnly = true, + fieldName = "Nem megerősített email elutasítása", + description = "Ha be van kapcsolva, a Google/Keycloak belépés elutasításra kerül, ha az email cím nincs megerősítve. Nem jön létre duplikált felhasználó.") + + var restrictCrossProviderMerge by BooleanSettingRef(true, serverSideOnly = true, + fieldName = "Provider-közti összevonás tiltása", + description = "Ha be van kapcsolva, egy meglévő fiók csak akkor vonható össze email alapján, ha ugyanaz a bejelentkezési provider hozta létre.") + + /// ------------------------------------------------------------------------------------------------------------------- + val googleSsoGroup by SettingGroup(fieldName = "Google SSO", description = "A körtagságok és egyéb körös funkciók ezzel nem működnek automatikusan") diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginRejectedException.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginRejectedException.kt new file mode 100644 index 00000000..13cc31e2 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginRejectedException.kt @@ -0,0 +1,7 @@ +package hu.bme.sch.cmsch.component.login + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.OAuth2Error + +class LoginRejectedException(val userMessage: String) : + OAuth2AuthenticationException(OAuth2Error("login_rejected", userMessage, null), userMessage) diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginService.kt index c2b78000..dbbfcabd 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/LoginService.kt @@ -91,9 +91,18 @@ class LoginService( user = existingByInternalId.get() log.info("Logging in with existing user ${user.fullName} as google user") } else { - val existingByEmail = users.findByEmailIgnoreCase(profile.email) + if (loginComponent.rejectUnverifiedEmail && !profile.emailVerified) { + log.warn("Rejecting google login for ${profile.email}: email is not verified") + throw LoginRejectedException("A belépéshez megerősített email cím szükséges.") + } + val existingByEmail = + if (profile.email.isNotBlank()) users.findByEmailIgnoreCase(profile.email) else Optional.empty() if (existingByEmail.isPresent) { user = existingByEmail.get() + if (loginComponent.restrictCrossProviderMerge && user.provider != GOOGLE) { + log.warn("Rejecting google login for ${profile.email}: existing account belongs to provider '${user.provider}'") + throw LoginRejectedException("Ehhez az email címhez másik bejelentkezési mód tartozik.") + } log.info("Logging in with existing user ${user.fullName} (found by email) as google user, internalId updated from ${user.internalId} to ${profile.internalId}") user.internalId = profile.internalId } else { @@ -402,10 +411,18 @@ class LoginService( user = existingByInternalId.get() log.info("Logging in with existing user ${user.fullName} as keycloak user") } else { + if (loginComponent.rejectUnverifiedEmail && !profile.emailVerified) { + log.warn("Rejecting keycloak login for ${profile.email}: email is not verified") + throw LoginRejectedException("A belépéshez megerősített email cím szükséges.") + } val existingByEmail = if (!profile.email.isBlank()) users.findByEmailIgnoreCase(profile.email) else Optional.empty() if (existingByEmail.isPresent) { user = existingByEmail.get() + if (loginComponent.restrictCrossProviderMerge && user.provider != KEYCLOAK) { + log.warn("Rejecting keycloak login for ${profile.email}: existing account belongs to provider '${user.provider}'") + throw LoginRejectedException("Ehhez az email címhez másik bejelentkezési mód tartozik.") + } log.info("Logging in with existing user ${user.fullName} (found by email) as keycloak user, internalId updated from ${user.internalId} to ${profile.sid}") user.internalId = profile.sid } else { diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/keycloak/KeycloakUserInfoResponse.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/keycloak/KeycloakUserInfoResponse.kt index 259cefc6..68d83b50 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/keycloak/KeycloakUserInfoResponse.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/keycloak/KeycloakUserInfoResponse.kt @@ -20,6 +20,9 @@ data class KeycloakUserInfoResponse( var email: String = "", + @set:JsonProperty("email_verified") + var emailVerified: Boolean = false, + @set:JsonProperty(required = false) @get:JsonProperty(required = false) var groups: List = mutableListOf(), diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/SecurityConfig.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/SecurityConfig.kt index e7a24567..5d1a28d0 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/SecurityConfig.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/SecurityConfig.kt @@ -3,6 +3,7 @@ package hu.bme.sch.cmsch.config import hu.bme.sch.cmsch.component.countdown.CountdownFilterConfigurer import hu.bme.sch.cmsch.component.login.CmschUserDetailsService import hu.bme.sch.cmsch.component.login.LoginComponent +import hu.bme.sch.cmsch.component.login.LoginRejectedException import hu.bme.sch.cmsch.component.login.LoginService import hu.bme.sch.cmsch.component.login.SessionFilterConfigurer import hu.bme.sch.cmsch.component.login.authsch.CmschAuthschUser @@ -41,6 +42,8 @@ import org.springframework.security.web.SecurityFilterChain import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.bodyToMono import tools.jackson.databind.ObjectMapper +import java.nio.charset.StandardCharsets +import java.net.URLEncoder import java.util.* @EnableWebSecurity @@ -184,6 +187,15 @@ class SecurityConfig( } .userService { resolveAuthschUser(it) } }.defaultSuccessUrl("/control/post-login") + .failureHandler { request, response, exception -> + val message = if (exception is LoginRejectedException) + exception.userMessage + else + "Sikertelen bejelentkezés." + log.info("OAuth2 login failed: ${exception.message}") + val encoded = URLEncoder.encode(message, StandardCharsets.UTF_8) + response.sendRedirect("/oauth2/authorization?error=$encoded") + } } serviceAccountFilterConfigurer.ifPresent { http.with(it, Customizer.withDefaults()) } countdownConfigurer.ifPresent { http.with(it, Customizer.withDefaults()) } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/admin/EntrypointController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/admin/EntrypointController.kt index a3fb7746..768db3e8 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/admin/EntrypointController.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/admin/EntrypointController.kt @@ -42,14 +42,17 @@ class EntrypointController( val authschEnabled = loginComponent.authschPromoted // AuthSCH option must always be available, so it's fine if we don't check it, otherwise nobody can log in - if (!googleEnabled && !keycloakEnabled) - return "redirect:/oauth2/authorization/authsch" + // When an error is present, skip auto-redirects so the message can be shown to the user + if (error.isBlank()) { + if (!googleEnabled && !keycloakEnabled) + return "redirect:/oauth2/authorization/authsch" - if (!keycloakEnabled && !authschEnabled) - return "redirect:/oauth2/authorization/google" + if (!keycloakEnabled && !authschEnabled) + return "redirect:/oauth2/authorization/google" - if (!authschEnabled && !googleEnabled) - return "redirect:/oauth2/authorization/keycloak" + if (!authschEnabled && !googleEnabled) + return "redirect:/oauth2/authorization/keycloak" + } response.status = HttpServletResponse.SC_UNAUTHORIZED model.addAttribute("showAuthSch", authschEnabled) From bac5d01dd9a717038bf06544d9a4cf7e892b35e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Istv=C3=A1n?= Date: Wed, 8 Jul 2026 22:34:37 +0200 Subject: [PATCH 3/3] Delete unregistered user tokens and make notification sending resilient, use custom auth flow instead of Google library --- backend/build.gradle.kts | 1 - .../FirebaseMessagingConfiguration.kt | 49 ------- .../PushNotificationService.kt | 131 ++++++++++++------ .../PushNotificationServiceCredentials.kt | 113 +++++++++++++++ 4 files changed, 202 insertions(+), 92 deletions(-) delete mode 100644 backend/src/main/kotlin/hu/bme/sch/cmsch/component/pushnotification/FirebaseMessagingConfiguration.kt create mode 100644 backend/src/main/kotlin/hu/bme/sch/cmsch/component/pushnotification/PushNotificationServiceCredentials.kt diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 82e8297e..d35ec7fb 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -52,7 +52,6 @@ dependencies { implementation("tools.jackson.dataformat:jackson-dataformat-csv") implementation("tools.jackson.module:jackson-module-kotlin") implementation("com.github.spullara.mustache.java:compiler:0.9.14") - implementation("com.google.auth:google-auth-library-oauth2-http:1.47.0") implementation("com.google.zxing:core:3.5.4") implementation("com.google.zxing:javase:3.5.4") implementation("com.itextpdf:itext-core:9.6.0") diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/pushnotification/FirebaseMessagingConfiguration.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/pushnotification/FirebaseMessagingConfiguration.kt deleted file mode 100644 index c49e30f4..00000000 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/pushnotification/FirebaseMessagingConfiguration.kt +++ /dev/null @@ -1,49 +0,0 @@ -package hu.bme.sch.cmsch.component.pushnotification - -import com.google.auth.oauth2.GoogleCredentials -import com.google.auth.oauth2.ServiceAccountCredentials -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.core.retry.RetryPolicy -import org.springframework.core.retry.RetryTemplate -import org.springframework.web.reactive.function.client.WebClient -import java.time.Duration - -@Configuration -@ConditionalOnBean(PushNotificationComponent::class) -class FirebaseMessagingConfiguration { - private val messagingScope = "https://www.googleapis.com/auth/firebase.messaging" - - @Bean - @ConditionalOnBean(PushNotificationComponent::class) - fun googleCredentials( - @Value("\${hu.bme.sch.cmsch.google.service-account-key}") accountKey: String, - ): GoogleCredentials = GoogleCredentials - .fromStream(accountKey.byteInputStream()) - .createScoped(listOf(messagingScope)) - - @Bean - @ConditionalOnBean(PushNotificationComponent::class) - fun fcmProjectId(credentials: GoogleCredentials): String = - (credentials as? ServiceAccountCredentials)?.projectId ?: "unknown" - - @Bean - @ConditionalOnBean(PushNotificationComponent::class) - fun fcmWebClient(builder: WebClient.Builder): WebClient = builder.baseUrl("https://fcm.googleapis.com/v1/projects/").build() - - @Bean - @ConditionalOnBean(PushNotificationComponent::class) - fun retryTemplate(): RetryTemplate { - val retryPolicy = RetryPolicy.builder() - .maxRetries(5) - .delay(Duration.ofMillis(500)) - .multiplier(1.5) - .build() - - val template = RetryTemplate() - template.setRetryPolicy(retryPolicy) - return template - } -} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/pushnotification/PushNotificationService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/pushnotification/PushNotificationService.kt index 672567e8..98e17a9d 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/pushnotification/PushNotificationService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/pushnotification/PushNotificationService.kt @@ -1,98 +1,139 @@ package hu.bme.sch.cmsch.component.pushnotification -import com.google.auth.oauth2.GoogleCredentials import hu.bme.sch.cmsch.dto.CmschNotification import hu.bme.sch.cmsch.model.RoleType -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.runBlocking +import hu.bme.sch.cmsch.util.transaction import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnBean import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Isolation +import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.annotation.Transactional import org.springframework.web.reactive.function.client.WebClient -import org.springframework.web.reactive.function.client.awaitBodilessEntity +import org.springframework.web.reactive.function.client.WebClientResponseException +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import reactor.util.retry.Retry +import tools.jackson.databind.ObjectMapper +import java.time.Duration +import java.util.concurrent.ConcurrentLinkedQueue + +private const val MAX_CONCURRENT_SENDS = 100 +private const val MAX_RETRIES = 4L +private val RETRY_INITIAL_BACKOFF = Duration.ofMillis(500) +private val RETRY_MAX_BACKOFF = Duration.ofSeconds(5) @Service @ConditionalOnBean(PushNotificationComponent::class) class PushNotificationService( - private val credentials: GoogleCredentials, - private val projectId: String, - private val fcmWebClient: WebClient, + @Value($$"${hu.bme.sch.cmsch.google.service-account-key}") accountKey: String, + webClientBuilder: WebClient.Builder, + objectMapper: ObjectMapper, + private val transactionManager: PlatformTransactionManager, private val messagingTokenRepository: MessagingTokenRepository, ) { private val log = LoggerFactory.getLogger(javaClass) + private val fcmWebClient = webClientBuilder.baseUrl("https://fcm.googleapis.com/v1/projects/").build() + private val credentials = PushNotificationServiceCredentials(accountKey, objectMapper) + - @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE) fun sendToUser(userId: Int, notification: CmschNotification): Int { log.info("Sending notification {} to user: {}", notification, userId) - val tokens = messagingTokenRepository.findAllTokensByUserId(userId) + val tokens = transactionManager.transaction(readOnly = true) { + messagingTokenRepository.findAllTokensByUserId(userId) + } return sendNotifications(tokens, notification) } - @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE) fun sendToGroup(groupId: Int, notification: CmschNotification): Int { log.info("Sending notification {} to group: {}", notification, groupId) - val tokens = messagingTokenRepository.findAllTokensByGroupId(groupId) + val tokens = transactionManager.transaction(readOnly = true) { + messagingTokenRepository.findAllTokensByGroupId(groupId) + } return sendNotifications(tokens, notification) } - @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE) fun sendToAllUsers(notification: CmschNotification): Int { log.info("Sending notification {} to all users", notification) - val tokens = messagingTokenRepository.findAllTokens() + val tokens = transactionManager.transaction(readOnly = true) { + messagingTokenRepository.findAllTokens() + } return sendNotifications(tokens, notification) } - @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE) fun sendToRole(role: RoleType, notification: CmschNotification): Int { log.info("Sending notification {} to users with role {}", notification, role.displayName) - val tokens = messagingTokenRepository.findAllTokensByRole(role) + val tokens = transactionManager.transaction(readOnly = true) { + messagingTokenRepository.findAllTokensByRole(role) + } return sendNotifications(tokens, notification) } private fun getAccessToken(): String { - credentials.refreshIfExpired() - return credentials.accessToken.tokenValue + return credentials.getAccessToken() } - private fun sendNotifications(tokens: List, notification: CmschNotification): Int = runBlocking { - if (tokens.isEmpty()) return@runBlocking 0 + private fun sendNotifications(tokens: List, notification: CmschNotification): Int { + if (tokens.isEmpty()) return 0 val accessToken = runCatching { getAccessToken() }.getOrElse { log.error("Failed to get FCM access token", it) - return@runBlocking 0 + return 0 } - val results = coroutineScope { - tokens.chunked(100).flatMap { chunk -> - chunk.map { token -> - async { - runCatching { - fcmWebClient.post() - .uri("{projectId}/messages:send", projectId) - .header("Authorization", "Bearer $accessToken") - .bodyValue(notification.toFcmRequest(token)) - .retrieve() - .awaitBodilessEntity() - true - }.getOrElse { e -> + val staleTokens = ConcurrentLinkedQueue() + + val results = Flux.fromIterable(tokens) + .flatMap({ token -> + fcmWebClient.post() + .uri("{projectId}/messages:send", credentials.projectId) + .header("Authorization", "Bearer $accessToken") + .bodyValue(notification.toFcmRequest(token)) + .retrieve() + .toBodilessEntity() + .retryWhen( + Retry.backoff(MAX_RETRIES, RETRY_INITIAL_BACKOFF) + .maxBackoff(RETRY_MAX_BACKOFF) + .filter(::isRetryable) + .doBeforeRetry { signal -> + log.error( + "Retryable error sending to token {} (attempt {}), retrying", + token, signal.totalRetries() + 1, signal.failure() + ) + } + ) + .thenReturn(SendResult.SUCCESS) + .onErrorResume { e -> + if (e is WebClientResponseException.NotFound) { + staleTokens.add(token) + Mono.just(SendResult.STALE) + } else { log.error("Failed to send notification to token {}", token, e) - false + Mono.just(SendResult.FAILURE) } } - }.awaitAll() + }, MAX_CONCURRENT_SENDS) + .collectList() + .block() ?: emptyList() + + if (staleTokens.isNotEmpty()) { + val deleted = transactionManager.transaction(readOnly = false) { + messagingTokenRepository.deleteByTokenIn(staleTokens.toList()) } + log.info("Deleted {} stale messaging tokens", deleted) } - val successCount = results.count { it } + val successCount = results.count { it == SendResult.SUCCESS } log.info("Successfully sent notification to {} out of {} devices", successCount, tokens.size) - successCount + return successCount + } + + private fun isRetryable(e: Throwable): Boolean = when (e) { + is WebClientResponseException -> e.statusCode.is5xxServerError || e.statusCode.value() == 429 + else -> true } - @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE) + @Transactional(readOnly = false) fun addToken(userId: Int, token: String) { if (messagingTokenRepository.existsByUserIdAndToken(userId, token)) return @@ -100,9 +141,15 @@ class PushNotificationService( messagingTokenRepository.save(MessagingTokenEntity(userId = userId, token = token)) } - @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE) + @Transactional(readOnly = false) fun deleteToken(userId: Int, token: String) { log.debug("Deleting messaging token for user: {}, token: {}", userId, token) messagingTokenRepository.deleteByUserIdAndToken(userId, token) } } + +private enum class SendResult { + SUCCESS, + STALE, + FAILURE, +} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/pushnotification/PushNotificationServiceCredentials.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/pushnotification/PushNotificationServiceCredentials.kt new file mode 100644 index 00000000..ad984bd5 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/pushnotification/PushNotificationServiceCredentials.kt @@ -0,0 +1,113 @@ +package hu.bme.sch.cmsch.component.pushnotification + +import io.jsonwebtoken.Jwts +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToMono +import tools.jackson.core.json.JsonReadFeature +import tools.jackson.databind.ObjectMapper +import tools.jackson.databind.json.JsonMapper +import tools.jackson.module.kotlin.readValue +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.time.Duration +import java.time.Instant +import java.util.* +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +private const val MESSAGING_SCOPE = "https://www.googleapis.com/auth/firebase.messaging" +private val TOKEN_VALIDITY = Duration.ofHours(1) +private val EXPIRY_SKEW = Duration.ofMinutes(1) + +class PushNotificationServiceCredentials( + accountKey: String, + private val objectMapper: ObjectMapper, +) { + private data class ServiceAccountKey( + val clientEmail: String, + val privateKey: String, + val tokenUri: String, + val projectId: String, + ) + + private data class TokenResponse( + val accessToken: String, + val expiresIn: Long, + ) + + private val key: ServiceAccountKey = "".let { + JsonMapper.builder().configure(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS, true).build() + .readValue>(accountKey).let { node -> + ServiceAccountKey( + projectId = node["project_id"]!!.toString(), + clientEmail = node["client_email"].toString(), + privateKey = node["private_key"].toString(), + tokenUri = node["token_uri"]?.toString() ?: "https://oauth2.googleapis.com/token", + ) + } + } + + private val privateKey: PrivateKey = parsePrivateKey(key.privateKey) + private val tokenWebClient: WebClient = WebClient.builder().build() + + private val lock = ReentrantLock() + private var accessToken: String? = null + private var expiresAt: Instant = Instant.EPOCH + + val projectId: String + get() = key.projectId + + fun getAccessToken(): String = lock.withLock { + val current = accessToken + if (current != null && Instant.now().isBefore(expiresAt.minus(EXPIRY_SKEW))) { + return current + } + val response = fetchToken() + accessToken = response.accessToken + expiresAt = Instant.now().plusSeconds(response.expiresIn) + response.accessToken + } + + private fun fetchToken(): TokenResponse { + val now = Instant.now() + val assertion = Jwts.builder() + .issuer(key.clientEmail) + .claim("aud", key.tokenUri) + .claim("scope", MESSAGING_SCOPE) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(TOKEN_VALIDITY))) + .signWith(privateKey, Jwts.SIG.RS256) + .compact() + + val body = tokenWebClient.post() + .uri(key.tokenUri) + .header("Content-Type", "application/x-www-form-urlencoded") + .body( + BodyInserters.fromFormData("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + .with("assertion", assertion) + ) + .retrieve() + .bodyToMono() + .block() ?: throw IllegalStateException("Empty token response from ${key.tokenUri}") + + val node = objectMapper.readTree(body) + return TokenResponse( + accessToken = node.get("access_token").asString(), + expiresIn = node.get("expires_in")?.asLong() ?: TOKEN_VALIDITY.seconds, + ) + } + + private fun parsePrivateKey(pem: String): PrivateKey { + val cleaned = pem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\n", "") + .replace("\n", "") + .replace("\r", "") + .trim() + val decoded = Base64.getDecoder().decode(cleaned) + return KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(decoded)) + } +}