checkpoint before checking out feature/account-managed-gateway

This commit is contained in:
Rocky
2026-08-19 15:53:08 +08:00
parent 25cfbfa4e6
commit 1737106560
51 changed files with 1837 additions and 52 deletions
@@ -28,8 +28,9 @@ import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.account.AccountRepository
import com.osglab.account.features.account.AccountOperations
import com.osglab.account.features.account.AccountReauthenticator
import com.osglab.account.features.account.AccountRepository
import com.osglab.account.features.account.AccountService
import com.osglab.account.features.account.AppleAccountReauthenticator
import com.osglab.account.features.account.AppleRevocationOutboxProcessor
@@ -103,12 +104,18 @@ import com.osglab.account.features.integrity.integrityRoutes
import com.osglab.account.features.inviteweb.InviteWebConfig
import com.osglab.account.features.inviteweb.ReferralLookupPort
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
import com.osglab.account.features.referrals.domain.ReferralException
import com.osglab.account.features.referrals.routes.referralRoutes
import com.osglab.account.features.referrals.services.ReferralOperations
import com.osglab.account.features.referrals.services.ReferralService
import com.osglab.account.features.referrals.services.ReferralRiskIdentity
import com.osglab.account.features.referrals.services.ReferralRiskProvider
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.routes.storeKitRoutes
import com.osglab.account.features.storekit.services.StoreKitService
import com.osglab.account.features.storekit.verification.AppleStoreKitTransactionVerifier
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
@@ -288,6 +295,7 @@ fun Application.module() {
accountRoutes(koin.get())
creditRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), koin.get())
storeKitRoutes(koin.get(), koin.get())
}
rateLimit(GATEWAY_RATE_LIMIT) {
configureGatewayRoutes(
@@ -431,6 +439,23 @@ fun accountServerModule(config: AppConfig): Module = module {
)
}
single<CreditOperations> { get<CreditService>() }
single<StoreKitTransactionVerifier> {
if (config.storeKit.enabled) {
AppleStoreKitTransactionVerifier(
bundleId = config.storeKit.bundleId,
appAppleId = requireNotNull(config.storeKit.appAppleId),
)
} else {
StoreKitTransactionVerifier { throw StoreKitUnavailable() }
}
}
single {
StoreKitService(
products = if (config.storeKit.enabled) config.storeKit.products else emptyList(),
verifier = get(),
transactions = get(),
)
}
single<TrialCreditGranter> {
TrialCreditGranter { accountId ->
get<CreditService>().grantSignupTrial(
@@ -454,7 +479,7 @@ fun accountServerModule(config: AppConfig): Module = module {
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
single<AccountProvisioner> {
AccountProvisioner { accountId, deviceCheckToken ->
AccountProvisioner { accountId, deviceCheckToken, displayName ->
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
if (deviceCheckToken != null && !granted) {
get<AuthRepository>().restrictAccountForAntiAbuse(
@@ -462,6 +487,14 @@ fun accountServerModule(config: AppConfig): Module = module {
java.time.Instant.now(),
)
}
get<AccountService>().seedDisplayName(accountId, displayName)
try {
get<ReferralOperations>().getOrCreateCode(accountId)
} catch (exception: CancellationException) {
throw exception
} catch (_: ReferralException) {
// Referral eligibility must not make account sign-in unavailable.
}
}
}
single {
@@ -494,6 +527,7 @@ fun accountServerModule(config: AppConfig): Module = module {
reauthenticator = get(),
)
}
single<AccountOperations> { get<AccountService>() }
single<UserRegistrationTimeProvider> {
UserRegistrationTimeProvider { accountId ->
@@ -1,5 +1,6 @@
package com.osglab.account.config
import com.osglab.account.features.storekit.domain.StoreKitProduct
import io.ktor.server.config.ApplicationConfig
import java.net.URI
import java.util.Base64
@@ -16,6 +17,7 @@ data class AppConfig(
val antiAbuse: AntiAbuseConfig,
val apple: AppleConfig,
val credits: CreditsConfig,
val storeKit: StoreKitConfig = StoreKitConfig(),
val providers: ProvidersConfig,
val integrity: IntegrityConfig,
val admin: AdminConfig = AdminConfig(),
@@ -76,10 +78,32 @@ data class AppConfig(
)
val credits = CreditsConfig(
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
referralInviter = config.positiveLong("app.credits.referralInviter", 3_000),
referralInvitee = config.positiveLong("app.credits.referralInvitee", 3_000),
referralInviter = config.positiveLong("app.credits.referralInviter", 1_000),
referralInvitee = config.positiveLong("app.credits.referralInvitee", 1_000),
referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7),
)
val storeKitEnabled = config.booleanOrDefault("app.storeKit.enabled", false)
val storeKitAppAppleId = config.optionalValue("app.storeKit.appAppleId")?.let { raw ->
raw.toLongOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("app.storeKit.appAppleId must be positive")
}
val storeKit = StoreKitConfig(
enabled = storeKitEnabled,
bundleId = config.valueOrDefault("app.storeKit.bundleId", apple.clientId),
appAppleId = storeKitAppAppleId,
products = config.storeKitProducts("app.storeKit.products"),
)
if (storeKitEnabled) {
require(storeKit.bundleId == apple.clientId) {
"app.storeKit.bundleId must match app.apple.clientId"
}
require(storeKit.appAppleId != null) {
"app.storeKit.appAppleId is required when StoreKit is enabled"
}
require(storeKit.products.isNotEmpty()) {
"app.storeKit.products is required when StoreKit is enabled"
}
}
val providers = ProvidersConfig(
volcengine = VolcengineConfig(
endpoint = config.valueOrDefault(
@@ -294,6 +318,7 @@ data class AppConfig(
antiAbuse = antiAbuse,
apple = apple,
credits = credits,
storeKit = storeKit,
providers = providers,
integrity = integrity,
admin = admin,
@@ -359,6 +384,13 @@ data class CreditsConfig(
val referralBindingDays: Long,
)
data class StoreKitConfig(
val enabled: Boolean = false,
val bundleId: String = "com.osgkeyboard.ios",
val appAppleId: Long? = null,
val products: List<StoreKitProduct> = emptyList(),
)
data class ProvidersConfig(
val volcengine: VolcengineConfig,
val deepSeek: DeepSeekConfig,
@@ -492,6 +524,24 @@ private fun ApplicationConfig.positiveLong(path: String, default: Long): Long =
?: throw ConfigValidationException("$path must be a positive integer")
} ?: default
private fun ApplicationConfig.storeKitProducts(path: String): List<StoreKitProduct> {
val raw = optionalValue(path) ?: return emptyList()
val products = raw.split(',').map { entry ->
val parts = entry.split(':', limit = 2).map(String::trim)
if (parts.size != 2) {
throw ConfigValidationException("$path must use productId:credits entries")
}
val credits = parts[1].toLongOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("$path credits must be positive integers")
runCatching { StoreKitProduct(productId = parts[0], credits = credits) }
.getOrElse { throw ConfigValidationException("$path contains an invalid product", it) }
}
if (products.map(StoreKitProduct::productId).distinct().size != products.size) {
throw ConfigValidationException("$path contains duplicate product IDs")
}
return products
}
private fun ApplicationConfig.boolean(path: String): Boolean =
required(path).let {
when (it.lowercase()) {
@@ -26,11 +26,13 @@ data class AccountRecord(
val identityFingerprint: String,
val antiAbuseRestricted: Boolean,
val encryptedAppleRefreshToken: String?,
val encryptedDisplayName: String? = null,
val createdAt: Instant,
) {
override fun toString(): String =
"AccountRecord(id=$id, identityFingerprint=[REDACTED], " +
"antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " +
"encryptedDisplayName=[REDACTED], " +
"createdAt=$createdAt)"
}
@@ -61,8 +63,18 @@ internal object AppleRevocationOutboxTable : Table("apple_revocation_outbox") {
override val primaryKey = PrimaryKey(id)
}
private object AccountProfilesTable : Table("account_profiles") {
val accountId = varchar("account_id", 36)
val encryptedDisplayName = text("encrypted_display_name")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(accountId)
}
interface AccountRepository {
suspend fun findById(accountId: UUID): AccountRecord?
suspend fun seedDisplayNameIfAbsent(accountId: UUID, encryptedDisplayName: String, now: Instant)
suspend fun updateDisplayName(accountId: UUID, encryptedDisplayName: String, now: Instant)
suspend fun deleteById(
accountId: UUID,
deletedAt: Instant,
@@ -89,17 +101,58 @@ class ExposedAccountRepository(
.where { AppleCredentialsTable.accountId eq accountId.toString() }
.singleOrNull()
?.get(AppleCredentialsTable.encryptedRefreshToken)
val encryptedDisplayName = AccountProfilesTable.selectAll()
.where { AccountProfilesTable.accountId eq accountId.toString() }
.singleOrNull()
?.get(AccountProfilesTable.encryptedDisplayName)
row.let {
AccountRecord(
id = UUID.fromString(it[AccountsTable.id]),
identityFingerprint = fingerprint,
antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted],
encryptedAppleRefreshToken = encryptedRefreshToken,
encryptedDisplayName = encryptedDisplayName,
createdAt = it[AccountsTable.createdAt],
)
}
}
override suspend fun seedDisplayNameIfAbsent(
accountId: UUID,
encryptedDisplayName: String,
now: Instant,
) {
databaseFactory.query {
AccountProfilesTable.insertIgnore {
it[AccountProfilesTable.accountId] = accountId.toString()
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
it[createdAt] = now
it[updatedAt] = now
}
}
}
override suspend fun updateDisplayName(
accountId: UUID,
encryptedDisplayName: String,
now: Instant,
) {
databaseFactory.query {
AccountProfilesTable.insertIgnore {
it[AccountProfilesTable.accountId] = accountId.toString()
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
it[createdAt] = now
it[updatedAt] = now
}
AccountProfilesTable.update({
AccountProfilesTable.accountId eq accountId.toString()
}) {
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
it[updatedAt] = now
}
}
}
override suspend fun deleteById(
accountId: UUID,
deletedAt: Instant,
@@ -12,6 +12,7 @@ import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.patch
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
@@ -19,8 +20,12 @@ import kotlinx.serialization.Serializable
data class AccountResponse(
val id: String,
val createdAtEpochSeconds: Long,
val displayName: String?,
)
@Serializable
data class UpdateAccountProfileRequest(val displayName: String)
@Serializable
data class DeleteAccountRequest(
val identityToken: String,
@@ -39,7 +44,7 @@ data class DeleteAccountRequest(
}
class AccountRoutes(
private val accountService: AccountService,
private val accountService: AccountOperations,
) {
fun register(parent: Route) {
with(parent) {
@@ -49,14 +54,17 @@ class AccountRoutes(
val principal = call.principal<AccountPrincipal>()
?: throw UnauthorizedException()
val account = accountService.get(principal.userId)
call.respond(
ApiResponse(
data = AccountResponse(
id = account.id.toString(),
createdAtEpochSeconds = account.createdAt.epochSecond,
),
),
call.respond(ApiResponse(data = account.toResponse()))
}
patch {
val principal = call.principal<AccountPrincipal>()
?: throw UnauthorizedException()
val request = call.receive<UpdateAccountProfileRequest>()
val account = accountService.updateDisplayName(
principal.userId,
request.displayName,
)
call.respond(ApiResponse(data = account.toResponse()))
}
delete {
val principal = call.principal<AccountPrincipal>()
@@ -73,5 +81,11 @@ class AccountRoutes(
}
}
fun Route.accountRoutes(accountService: AccountService) =
fun Route.accountRoutes(accountService: AccountOperations) =
AccountRoutes(accountService).register(this)
private fun AccountView.toResponse() = AccountResponse(
id = id.toString(),
createdAtEpochSeconds = createdAt.epochSecond,
displayName = displayName,
)
@@ -1,6 +1,7 @@
package com.osglab.account.features.account
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.FieldDecryptionException
import com.osglab.account.common.security.FieldEncryptor
@@ -16,11 +17,13 @@ import kotlinx.coroutines.CancellationException
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.text.Normalizer
import java.util.UUID
data class AccountView(
val id: UUID,
val createdAt: Instant,
val displayName: String?,
)
data class AppleReauthenticationProof(
@@ -41,6 +44,13 @@ fun interface AccountReauthenticator {
suspend fun verify(account: AccountRecord, proof: AppleReauthenticationProof): String
}
interface AccountOperations {
suspend fun get(accountId: UUID): AccountView
suspend fun seedDisplayName(accountId: UUID, candidate: String?)
suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof)
}
class AppleAccountReauthenticator(
private val identityVerifier: AppleIdentityTokenVerifier,
private val appleTokenClient: AppleTokenClient,
@@ -94,13 +104,34 @@ class AccountService(
private val revocationProcessor: AppleRevocationOutboxProcessor,
private val reauthenticator: AccountReauthenticator,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun get(accountId: UUID): AccountView {
) : AccountOperations {
override suspend fun get(accountId: UUID): AccountView {
val account = repository.findById(accountId) ?: throw UnauthorizedException()
return AccountView(account.id, account.createdAt)
return account.toView()
}
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
override suspend fun seedDisplayName(accountId: UUID, candidate: String?) {
val displayName = candidate?.let(::normalizedDisplayNameOrNull) ?: return
val account = repository.findById(accountId) ?: return
repository.seedDisplayNameIfAbsent(
accountId,
fieldEncryptor.encrypt(displayName, accountProfileContext(account.id)),
clock.instant(),
)
}
override suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView {
val account = repository.findById(accountId) ?: throw UnauthorizedException()
val displayName = normalizedDisplayName(candidate)
repository.updateDisplayName(
accountId,
fieldEncryptor.encrypt(displayName, accountProfileContext(account.id)),
clock.instant(),
)
return requireNotNull(repository.findById(accountId)).toView()
}
override suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
val account = repository.findById(accountId) ?: return
val now = clock.instant()
val currentRefreshToken = reauthenticator.verify(account, proof)
@@ -126,6 +157,14 @@ class AccountService(
// Local deletion is final. The durable outbox retry loop handles Apple outages.
}
}
private fun AccountRecord.toView(): AccountView = AccountView(
id = id,
createdAt = createdAt,
displayName = encryptedDisplayName?.let {
fieldEncryptor.decrypt(it, accountProfileContext(id))
},
)
}
class AppleRevocationOutboxProcessor(
@@ -175,3 +214,24 @@ class AppleRevocationOutboxProcessor(
}
fun appleRevocationContext(id: UUID): String = "apple-revocation-outbox:$id"
private fun accountProfileContext(id: UUID): String = "account-profile:$id"
private fun normalizedDisplayNameOrNull(candidate: String): String? =
runCatching { normalizedDisplayName(candidate) }.getOrNull()
private fun normalizedDisplayName(candidate: String): String {
val normalized = Normalizer.normalize(candidate.trim(), Normalizer.Form.NFC)
.replace(WHITESPACE_REGEX, " ")
if (
normalized.isBlank() ||
normalized.codePointCount(0, normalized.length) > MAX_DISPLAY_NAME_CODE_POINTS ||
normalized.any { it.isISOControl() }
) {
throw InvalidRequestException("Display name is invalid")
}
return normalized
}
private val WHITESPACE_REGEX = Regex("\\s+")
private const val MAX_DISPLAY_NAME_CODE_POINTS = 64
@@ -21,12 +21,14 @@ data class AppleSignInRequest(
val identityToken: String,
val authorizationCode: String,
val nonce: String,
val displayName: String? = null,
val deviceCheckToken: String? = null,
val appAttest: AppAttestRequest? = null,
) {
override fun toString(): String =
"AppleSignInRequest(identityToken=[REDACTED], authorizationCode=[REDACTED], " +
"nonce=[REDACTED], deviceCheckToken=[REDACTED], appAttest=[REDACTED])"
"nonce=[REDACTED], displayName=[REDACTED], deviceCheckToken=[REDACTED], " +
"appAttest=[REDACTED])"
}
@Serializable
@@ -73,6 +75,7 @@ class AuthRoutes(
identityToken = request.identityToken,
authorizationCode = request.authorizationCode,
nonce = request.nonce,
displayName = request.displayName,
integrityEvidence = IntegrityEvidence(
deviceCheckToken = request.deviceCheckToken,
appAttest = request.appAttest?.let {
@@ -34,7 +34,7 @@ data class SessionTokens(
}
fun interface AccountProvisioner {
suspend fun provision(accountId: UUID, deviceCheckToken: String?)
suspend fun provision(accountId: UUID, deviceCheckToken: String?, displayName: String?)
}
class SessionService(
@@ -46,7 +46,7 @@ class SessionService(
private val fieldEncryptor: FieldEncryptor,
private val identityFingerprint: IdentityFingerprint,
private val sessionConfig: SessionConfig,
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _ -> },
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _, _ -> },
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
private val clock: Clock = Clock.systemUTC(),
) {
@@ -55,6 +55,7 @@ class SessionService(
authorizationCode: String,
nonce: String,
integrityEvidence: IntegrityEvidence,
displayName: String? = null,
): SessionTokens {
requireValue(identityToken, "identityToken", MAX_IDENTITY_TOKEN_LENGTH)
requireValue(authorizationCode, "authorizationCode", MAX_AUTHORIZATION_CODE_LENGTH)
@@ -90,6 +91,7 @@ class SessionService(
accountProvisioner.provision(
account.id,
verifiedIntegrity.deviceCheckTokenForTrial.takeUnless { account.antiAbuseRestricted },
displayName,
)
return createSession(account.id, now)
}
@@ -51,6 +51,11 @@ data class CreditAccount(
val updatedAt: Instant,
)
data class CreditAccountSummary(
val account: CreditAccount,
val lifetimeUsed: Long,
)
data class LedgerEntry(
val id: UUID,
val userId: UUID,
@@ -1,6 +1,6 @@
package com.osglab.account.features.credits.models
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditAccountSummary
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.InvalidCreditRequest
@@ -64,13 +64,15 @@ data class SettleCreditsRequest(
data class CreditAccountDto(
val userId: String,
val balance: Long,
val lifetimeUsed: Long,
val updatedAt: String,
) {
companion object {
fun fromDomain(account: CreditAccount) = CreditAccountDto(
userId = account.userId.toString(),
balance = account.balance,
updatedAt = account.updatedAt.toString(),
fun fromDomain(summary: CreditAccountSummary) = CreditAccountDto(
userId = summary.account.userId.toString(),
balance = summary.account.balance,
lifetimeUsed = summary.lifetimeUsed,
updatedAt = summary.account.updatedAt.toString(),
)
}
}
@@ -8,6 +8,7 @@ import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.referrals.repositories.ReferralsRepository
import com.osglab.account.features.storekit.repositories.StoreKitRepository
import java.time.Instant
import java.util.UUID
@@ -28,6 +29,8 @@ interface CreditsRepository {
fun insertUsageRecord(record: CreditUsageRecord)
fun lifetimeUsedCredits(userId: UUID): Long
fun findReservationByReserveKey(userId: UUID, idempotencyKey: String): CreditReservation?
fun lockReservation(id: UUID): CreditReservation?
@@ -52,6 +55,7 @@ interface BillingUnitOfWork {
val credits: CreditsRepository
val referrals: ReferralsRepository
val adminCreditGrants: AdminCreditGrantRepository
val storeKit: StoreKitRepository
}
interface BillingTransactionRunner {
@@ -19,6 +19,8 @@ import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import com.osglab.account.features.referrals.repositories.ReferralsRepository
import com.osglab.account.features.storekit.repositories.ExposedStoreKitRepository
import com.osglab.account.features.storekit.repositories.StoreKitRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.core.*
@@ -27,6 +29,7 @@ import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
@@ -184,6 +187,7 @@ private object ExposedBillingUnitOfWork : BillingUnitOfWork {
override val credits: CreditsRepository = ExposedCreditsRepository
override val referrals: ReferralsRepository = ExposedReferralsRepository
override val adminCreditGrants: AdminCreditGrantRepository = ExposedAdminCreditGrantRepository
override val storeKit: StoreKitRepository = ExposedStoreKitRepository
}
private object ExposedCreditsRepository : CreditsRepository {
@@ -282,6 +286,25 @@ private object ExposedCreditsRepository : CreditsRepository {
}
}
override fun lifetimeUsedCredits(userId: UUID): Long {
val chargedTotal = CreditUsageRecords.chargedCredits.sum()
val charged = CreditUsageRecords
.select(chargedTotal)
.where { CreditUsageRecords.userId eq userId.toString() }
.single()[chargedTotal] ?: 0
val refundTotal = CreditLedger.amountDelta.sum()
val refunded = CreditLedger
.select(refundTotal)
.where {
(CreditLedger.userId eq userId.toString()) and
(CreditLedger.entryType eq LedgerEntryType.USAGE_REFUND)
}
.single()[refundTotal] ?: 0
return Math.subtractExact(charged, refunded).also {
check(it >= 0) { "Refunded credits exceed settled usage" }
}
}
override fun findReservationByReserveKey(
userId: UUID,
idempotencyKey: String,
@@ -36,7 +36,7 @@ class CreditRouteInstaller(
parent.route("/v1/credits") {
get("/balance") {
call.creditCall(authenticatedUser) { userId ->
CreditAccountDto.fromDomain(service.getAccount(userId))
CreditAccountDto.fromDomain(service.getAccountSummary(userId))
}
}
get("/ledger") {
@@ -4,6 +4,7 @@ import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditAccountSummary
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditCostCalculator
import com.osglab.account.features.credits.domain.CreditNotFound
@@ -46,6 +47,8 @@ data class ReferralRewardConfig(
interface CreditOperations {
suspend fun getAccount(userId: UUID): CreditAccount
suspend fun getAccountSummary(userId: UUID): CreditAccountSummary
suspend fun listEffectiveRates(): List<CreditRateVersion>
suspend fun listLedger(userId: UUID, limit: Int = 50): List<LedgerEntry>
@@ -107,6 +110,15 @@ class CreditService(
unit.credits.lockAccount(userId)
}
override suspend fun getAccountSummary(userId: UUID): CreditAccountSummary =
transactions.inTransaction { unit ->
unit.credits.createAccountIfAbsent(userId, clock.instant())
CreditAccountSummary(
account = unit.credits.lockAccount(userId),
lifetimeUsed = unit.credits.lifetimeUsedCredits(userId),
)
}
override suspend fun listEffectiveRates(): List<CreditRateVersion> =
transactions.inTransaction { it.credits.listEffectiveRates(clock.instant()) }
@@ -22,6 +22,7 @@ import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.slf4j.LoggerFactory
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Clock
@@ -283,10 +284,13 @@ class AppAttestService(
)
IntegrityVerification.Verified
} catch (exception: AppAttestRejectedException) {
APP_ATTEST_LOG.warn("App Attest assertion rejected: {}", exception.message)
IntegrityVerification.Rejected(exception.message ?: "App Attest rejected the assertion")
} catch (exception: InvalidRequestException) {
APP_ATTEST_LOG.warn("App Attest assertion request rejected: {}", exception.message)
IntegrityVerification.Rejected(exception.message)
} catch (exception: AppAttestUnavailableException) {
APP_ATTEST_LOG.error("App Attest assertion verification unavailable", exception)
IntegrityVerification.Unavailable(exception.message ?: "App Attest verification is unavailable")
} catch (exception: CancellationException) {
throw exception
@@ -392,6 +396,7 @@ class AppAttestService(
const val SHA256_BYTES = 32
const val MAX_ATTESTATION_BYTES = 256 * 1024
const val MAX_ASSERTION_BYTES = 64 * 1024
val APP_ATTEST_LOG = LoggerFactory.getLogger(AppAttestService::class.java)
}
}
@@ -251,11 +251,11 @@ class LibraryAppAttestCrypto(
} catch (exception: Exception) {
throw AppAttestUnavailableException("Stored App Attest public key is invalid", exception)
}
val signedBytes = authenticatorDataBytes + clientDataHash
val nonce = sha256(authenticatorDataBytes + clientDataHash)
val verified = try {
Signature.getInstance("SHA256withECDSA").run {
initVerify(key)
update(signedBytes)
update(nonce)
verify(signatureBytes)
}
} catch (exception: Exception) {
@@ -299,9 +299,10 @@ class LibraryAppAttestCrypto(
val rpHash = ByteArray(SHA256_BYTES).also(buffer::get)
val flags = buffer.get().toInt() and 0xff
val count = buffer.int.toLong() and UINT32_MASK
if ((flags and FLAG_ATTESTED_CREDENTIAL_DATA) != 0 ||
(flags and FLAG_EXTENSION_DATA) != 0
) {
// Production App Attest assertions can set AT while still using
// Apple's fixed 37-byte assertion profile. Exact-length validation
// above ensures no attested credential bytes are appended.
if ((flags and FLAG_EXTENSION_DATA) != 0) {
throw AppAttestRejectedException("App Attest assertion flags are invalid")
}
return AssertionAuthenticatorData(rpHash, flags, count)
@@ -32,6 +32,7 @@ import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.slf4j.LoggerFactory
import java.security.KeyFactory
import java.security.interfaces.ECPrivateKey
import java.security.spec.PKCS8EncodedKeySpec
@@ -233,7 +234,7 @@ class KtorAppleDeviceCheckClient(
private companion object {
const val MAX_DEVICE_TOKEN_LENGTH = 8_192
const val DEFAULT_TIMEOUT_MILLIS = 5_000L
const val DEFAULT_TIMEOUT_MILLIS = 15_000L
const val BIT_STATE_NOT_FOUND_RESPONSE = "Failed to find bit state"
val JSON = Json { ignoreUnknownKeys = true }
}
@@ -263,8 +264,13 @@ class RemoteDeviceCheckVerifier(
} catch (exception: DeviceCheckRejectedException) {
IntegrityVerification.Rejected(exception.message ?: "DeviceCheck rejected the token")
} catch (exception: DeviceCheckUnavailableException) {
LOG.warn("DeviceCheck verification unavailable: {}", exception.message)
IntegrityVerification.Unavailable(exception.message ?: "DeviceCheck is unavailable")
}
private companion object {
val LOG = LoggerFactory.getLogger(RemoteDeviceCheckVerifier::class.java)
}
}
open class DeviceCheckException(message: String, cause: Throwable? = null) :
@@ -177,13 +177,15 @@ class ReferralService(
}
}
override suspend fun getProfile(userId: UUID): ReferralProfile =
transactions.inTransaction { unit ->
override suspend fun getProfile(userId: UUID): ReferralProfile {
val activeCode = getOrCreateCode(userId)
return transactions.inTransaction { unit ->
ReferralProfile(
code = unit.referrals.findCodeByOwner(userId),
code = activeCode,
binding = unit.referrals.findBinding(userId),
)
}
}
override suspend fun listActiveCampaigns(): List<ReferralCampaign> =
transactions.inTransaction { it.referrals.listActiveCampaigns(clock.instant()) }
@@ -0,0 +1,66 @@
package com.osglab.account.features.storekit.domain
import java.time.Instant
import java.util.UUID
enum class StoreKitEnvironment {
SANDBOX,
PRODUCTION,
}
data class StoreKitProduct(
val productId: String,
val credits: Long,
) {
init {
require(PRODUCT_ID.matches(productId)) { "StoreKit product ID is invalid" }
require(credits > 0) { "StoreKit product credits must be positive" }
}
private companion object {
val PRODUCT_ID = Regex("[A-Za-z0-9._-]{3,128}")
}
}
data class VerifiedStoreKitTransaction(
val transactionId: String,
val originalTransactionId: String,
val appAccountToken: UUID,
val productId: String,
val environment: StoreKitEnvironment,
val purchasedAt: Instant,
val signedAt: Instant,
val revokedAt: Instant?,
)
data class StoreKitCreditPurchase(
val id: UUID,
val transactionId: String,
val originalTransactionId: String,
val userId: UUID,
val appAccountToken: UUID,
val productId: String,
val environment: StoreKitEnvironment,
val creditsGranted: Long,
val ledgerEntryId: UUID,
val signedTransactionSha256: String,
val purchasedAt: Instant,
val signedAt: Instant,
val createdAt: Instant,
)
data class StoreKitPurchaseResult(
val purchase: StoreKitCreditPurchase,
val balanceAfter: Long,
val replayed: Boolean,
)
sealed class StoreKitException(message: String) : RuntimeException(message)
class StoreKitUnavailable : StoreKitException("StoreKit credit purchases are unavailable")
class InvalidStoreKitRequest(message: String) : StoreKitException(message)
class StoreKitVerificationFailed : StoreKitException("The App Store transaction could not be verified")
class StoreKitPurchaseConflict : StoreKitException("The App Store transaction conflicts with an existing purchase")
@@ -0,0 +1,42 @@
package com.osglab.account.features.storekit.models
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
import kotlinx.serialization.Serializable
@Serializable
data class StoreKitProductDto(
val productId: String,
val credits: Long,
) {
companion object {
fun fromDomain(product: StoreKitProduct): StoreKitProductDto =
StoreKitProductDto(product.productId, product.credits)
}
}
@Serializable
data class StoreKitSubmitRequest(
val signedTransaction: String,
)
@Serializable
data class StoreKitPurchaseResponse(
val transactionId: String,
val productId: String,
val creditsGranted: Long,
val balanceAfter: Long,
val replayed: Boolean,
) {
companion object {
fun fromDomain(result: StoreKitPurchaseResult): StoreKitPurchaseResponse =
StoreKitPurchaseResponse(
transactionId = result.purchase.transactionId,
productId = result.purchase.productId,
creditsGranted = result.purchase.creditsGranted,
balanceAfter = result.balanceAfter,
replayed = result.replayed,
)
}
}
@@ -0,0 +1,77 @@
package com.osglab.account.features.storekit.repositories
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import java.util.UUID
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
interface StoreKitRepository {
fun findByTransactionId(transactionId: String): StoreKitCreditPurchase?
fun insert(purchase: StoreKitCreditPurchase)
}
object ExposedStoreKitRepository : StoreKitRepository {
override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? =
StoreKitCreditPurchases
.selectAll()
.where { StoreKitCreditPurchases.transactionId eq transactionId }
.singleOrNull()
?.toStoreKitCreditPurchase()
override fun insert(purchase: StoreKitCreditPurchase) {
StoreKitCreditPurchases.insert {
it[id] = purchase.id.toString()
it[transactionId] = purchase.transactionId
it[originalTransactionId] = purchase.originalTransactionId
it[userId] = purchase.userId.toString()
it[appAccountToken] = purchase.appAccountToken.toString()
it[productId] = purchase.productId
it[environment] = purchase.environment
it[creditsGranted] = purchase.creditsGranted
it[ledgerEntryId] = purchase.ledgerEntryId.toString()
it[signedTransactionSha256] = purchase.signedTransactionSha256
it[purchasedAt] = purchase.purchasedAt
it[signedAt] = purchase.signedAt
it[createdAt] = purchase.createdAt
}
}
}
private object StoreKitCreditPurchases : Table("storekit_credit_purchases") {
val id = varchar("id", 36)
val transactionId = varchar("transaction_id", 64)
val originalTransactionId = varchar("original_transaction_id", 64)
val userId = varchar("user_id", 36)
val appAccountToken = varchar("app_account_token", 36)
val productId = varchar("product_id", 128)
val environment = enumerationByName<StoreKitEnvironment>("environment", 16)
val creditsGranted = long("credits_granted")
val ledgerEntryId = varchar("ledger_entry_id", 36)
val signedTransactionSha256 = char("signed_transaction_sha256", 64)
val purchasedAt = timestamp("purchased_at")
val signedAt = timestamp("signed_at")
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
StoreKitCreditPurchase(
id = UUID.fromString(this[StoreKitCreditPurchases.id]),
transactionId = this[StoreKitCreditPurchases.transactionId],
originalTransactionId = this[StoreKitCreditPurchases.originalTransactionId],
userId = UUID.fromString(this[StoreKitCreditPurchases.userId]),
appAccountToken = UUID.fromString(this[StoreKitCreditPurchases.appAccountToken]),
productId = this[StoreKitCreditPurchases.productId],
environment = this[StoreKitCreditPurchases.environment],
creditsGranted = this[StoreKitCreditPurchases.creditsGranted],
ledgerEntryId = UUID.fromString(this[StoreKitCreditPurchases.ledgerEntryId]),
signedTransactionSha256 = this[StoreKitCreditPurchases.signedTransactionSha256],
purchasedAt = this[StoreKitCreditPurchases.purchasedAt],
signedAt = this[StoreKitCreditPurchases.signedAt],
createdAt = this[StoreKitCreditPurchases.createdAt],
)
@@ -0,0 +1,89 @@
package com.osglab.account.features.storekit.routes
import com.osglab.account.common.api.ApiError
import com.osglab.account.common.api.ApiErrorResponse
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
import com.osglab.account.features.storekit.models.StoreKitProductDto
import com.osglab.account.features.storekit.models.StoreKitPurchaseResponse
import com.osglab.account.features.storekit.models.StoreKitSubmitRequest
import com.osglab.account.features.storekit.services.StoreKitService
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
fun Route.storeKitRoutes(
service: StoreKitService,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
route("/v1/storekit") {
get("/products") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@get
}
call.respond(service.products().map(StoreKitProductDto::fromDomain))
}
post("/transactions") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@post
}
val request = call.receive<StoreKitSubmitRequest>()
try {
call.respond(
HttpStatusCode.OK,
StoreKitPurchaseResponse.fromDomain(
service.submit(userId, request.signedTransaction)
),
)
} catch (_: StoreKitUnavailable) {
call.respond(
HttpStatusCode.ServiceUnavailable,
ApiErrorResponse(
ApiError("external_service_unavailable", "Credit purchases are unavailable")
),
)
} catch (_: InvalidStoreKitRequest) {
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
)
} catch (_: StoreKitVerificationFailed) {
call.respond(
HttpStatusCode.UnprocessableEntity,
ApiErrorResponse(
ApiError("transaction_invalid", "The App Store transaction is invalid")
),
)
} catch (_: StoreKitPurchaseConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
} catch (_: CreditConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
}
}
}
}
@@ -0,0 +1,160 @@
package com.osglab.account.features.storekit.services
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.UUID
class StoreKitService(
products: List<StoreKitProduct>,
private val verifier: StoreKitTransactionVerifier,
private val transactions: BillingTransactionRunner,
private val clock: Clock = Clock.systemUTC(),
private val newId: () -> UUID = UUID::randomUUID,
) {
private val productsById = products.associateBy(StoreKitProduct::productId)
init {
require(productsById.size == products.size) { "StoreKit product IDs must be unique" }
}
fun products(): List<StoreKitProduct> = productsById.values.sortedBy(StoreKitProduct::credits)
suspend fun submit(
userId: UUID,
signedTransaction: String,
): StoreKitPurchaseResult {
if (productsById.isEmpty()) {
throw StoreKitUnavailable()
}
if (
signedTransaction.length !in MIN_SIGNED_TRANSACTION_LENGTH..MAX_SIGNED_TRANSACTION_LENGTH ||
signedTransaction != signedTransaction.trim()
) {
throw InvalidStoreKitRequest("signedTransaction is invalid")
}
val verified = verifier.verify(signedTransaction)
val product = productsById[verified.productId] ?: throw StoreKitPurchaseConflict()
validateTransaction(userId, verified)
val digest = signedTransaction.sha256Hex()
val idempotencyKey = "storekit:${verified.transactionId}"
return transactions.inTransaction { unit ->
val now = clock.instant()
unit.credits.createAccountIfAbsent(userId, now)
val account = unit.credits.lockAccount(userId)
unit.storeKit.findByTransactionId(verified.transactionId)?.let { existing ->
requireReplayMatches(existing, verified, product)
val ledger = unit.credits.findLedgerEntry(userId, idempotencyKey)
?: throw StoreKitPurchaseConflict()
if (
ledger.id != existing.ledgerEntryId ||
ledger.type != LedgerEntryType.STOREKIT_PURCHASE ||
ledger.amountDelta != existing.creditsGranted ||
ledger.referenceId != existing.id
) {
throw StoreKitPurchaseConflict()
}
return@inTransaction StoreKitPurchaseResult(existing, ledger.balanceAfter, replayed = true)
}
if (unit.credits.findLedgerEntry(userId, idempotencyKey) != null) {
throw StoreKitPurchaseConflict()
}
val balanceAfter = try {
Math.addExact(account.balance, product.credits)
} catch (_: ArithmeticException) {
throw CreditConflict("StoreKit credit balance overflow")
}
val purchaseId = newId()
val ledgerEntryId = newId()
val purchase = StoreKitCreditPurchase(
id = purchaseId,
transactionId = verified.transactionId,
originalTransactionId = verified.originalTransactionId,
userId = userId,
appAccountToken = verified.appAccountToken,
productId = product.productId,
environment = verified.environment,
creditsGranted = product.credits,
ledgerEntryId = ledgerEntryId,
signedTransactionSha256 = digest,
purchasedAt = verified.purchasedAt,
signedAt = verified.signedAt,
createdAt = now,
)
unit.credits.updateAccountBalance(userId, balanceAfter, now)
unit.credits.insertLedgerEntry(
LedgerEntry(
id = ledgerEntryId,
userId = userId,
type = LedgerEntryType.STOREKIT_PURCHASE,
amountDelta = product.credits,
balanceAfter = balanceAfter,
idempotencyKey = idempotencyKey,
referenceId = purchaseId,
createdAt = now,
)
)
unit.storeKit.insert(purchase)
StoreKitPurchaseResult(purchase, balanceAfter, replayed = false)
}
}
private fun validateTransaction(
userId: UUID,
transaction: VerifiedStoreKitTransaction,
) {
if (transaction.appAccountToken != userId || transaction.revokedAt != null) {
throw StoreKitPurchaseConflict()
}
val now = clock.instant()
if (
transaction.purchasedAt > transaction.signedAt ||
transaction.signedAt > now.plus(MAX_CLOCK_SKEW)
) {
throw StoreKitPurchaseConflict()
}
}
private fun requireReplayMatches(
existing: StoreKitCreditPurchase,
verified: VerifiedStoreKitTransaction,
product: StoreKitProduct,
) {
if (
existing.userId != verified.appAccountToken ||
existing.originalTransactionId != verified.originalTransactionId ||
existing.productId != verified.productId ||
existing.environment != verified.environment ||
existing.creditsGranted != product.credits ||
existing.purchasedAt != verified.purchasedAt
) {
throw StoreKitPurchaseConflict()
}
}
private fun String.sha256Hex(): String =
MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
private companion object {
const val MIN_SIGNED_TRANSACTION_LENGTH = 100
const val MAX_SIGNED_TRANSACTION_LENGTH = 32_768
val MAX_CLOCK_SKEW: Duration = Duration.ofMinutes(5)
}
}
@@ -0,0 +1,119 @@
package com.osglab.account.features.storekit.verification
import com.apple.itunes.storekit.model.Environment
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload
import com.apple.itunes.storekit.model.Type
import com.apple.itunes.storekit.verification.SignedDataVerifier
import com.apple.itunes.storekit.verification.VerificationException
import com.apple.itunes.storekit.verification.VerificationStatus
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import java.io.InputStream
import java.time.Instant
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
fun interface StoreKitTransactionVerifier {
suspend fun verify(signedTransaction: String): VerifiedStoreKitTransaction
}
class AppleStoreKitTransactionVerifier(
bundleId: String,
appAppleId: Long,
rootCertificateLoader: (String) -> InputStream? = {
AppleStoreKitTransactionVerifier::class.java.getResourceAsStream(it)
},
enableOnlineChecks: Boolean = true,
) : StoreKitTransactionVerifier {
private val production: SignedDataVerifier
private val sandbox: SignedDataVerifier
init {
val certificateBytes = ROOT_CERTIFICATES.map { path ->
rootCertificateLoader(path)?.use(InputStream::readAllBytes)
?: error("Missing Apple root certificate: $path")
}
fun verifier(environment: Environment): SignedDataVerifier {
val streams = certificateBytes.map(ByteArray::inputStream).toSet()
return SignedDataVerifier(
streams,
bundleId,
if (environment == Environment.PRODUCTION) appAppleId else null,
environment,
enableOnlineChecks,
).also { streams.forEach(InputStream::close) }
}
production = verifier(Environment.PRODUCTION)
sandbox = verifier(Environment.SANDBOX)
}
override suspend fun verify(
signedTransaction: String
): VerifiedStoreKitTransaction = withContext(Dispatchers.IO) {
if (signedTransaction.length !in MIN_JWS_LENGTH..MAX_JWS_LENGTH) {
throw StoreKitVerificationFailed()
}
var retryableFailure = false
val payload = try {
production.verifyAndDecodeTransaction(signedTransaction)
} catch (exception: VerificationException) {
retryableFailure = exception.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE
null
} ?: try {
sandbox.verifyAndDecodeTransaction(signedTransaction)
} catch (exception: VerificationException) {
retryableFailure = retryableFailure ||
exception.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE
null
}
if (payload == null && retryableFailure) {
throw StoreKitUnavailable()
}
if (payload == null) {
throw StoreKitVerificationFailed()
}
payload.toVerifiedTransaction()
}
private fun JWSTransactionDecodedPayload.toVerifiedTransaction(): VerifiedStoreKitTransaction {
val transaction = transactionId?.takeIf(TRANSACTION_ID::matches)
?: throw StoreKitVerificationFailed()
val original = originalTransactionId?.takeIf(TRANSACTION_ID::matches)
?: throw StoreKitVerificationFailed()
val accountToken = appAccountToken ?: throw StoreKitVerificationFailed()
val product = productId?.takeIf { it.length in 3..128 }
?: throw StoreKitVerificationFailed()
val purchaseMillis = purchaseDate?.takeIf { it > 0 } ?: throw StoreKitVerificationFailed()
val signedMillis = signedDate?.takeIf { it > 0 } ?: throw StoreKitVerificationFailed()
if (type != Type.CONSUMABLE || quantity != 1) {
throw StoreKitVerificationFailed()
}
val verifiedEnvironment = when (environment) {
Environment.PRODUCTION -> StoreKitEnvironment.PRODUCTION
Environment.SANDBOX -> StoreKitEnvironment.SANDBOX
else -> throw StoreKitVerificationFailed()
}
return VerifiedStoreKitTransaction(
transactionId = transaction,
originalTransactionId = original,
appAccountToken = accountToken,
productId = product,
environment = verifiedEnvironment,
purchasedAt = Instant.ofEpochMilli(purchaseMillis),
signedAt = Instant.ofEpochMilli(signedMillis),
revokedAt = revocationDate?.let(Instant::ofEpochMilli),
)
}
private companion object {
const val MIN_JWS_LENGTH = 100
const val MAX_JWS_LENGTH = 32_768
val TRANSACTION_ID = Regex("[0-9]{1,64}")
val ROOT_CERTIFICATES = listOf(
"/apple-pki/AppleRootCA-G2.cer",
"/apple-pki/AppleRootCA-G3.cer",
)
}
}