Establish secure account and managed AI backend

Provide the production foundation for Apple identity, immutable credits, referrals, integrity checks, managed providers, and hardened Docker deployment.
This commit is contained in:
Rocky
2026-08-16 14:46:23 +08:00
commit 0af35d44f4
124 changed files with 21052 additions and 0 deletions
@@ -0,0 +1,511 @@
package com.osglab.account
import com.osglab.account.common.api.installApiStatusPages
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.common.security.SessionJwt
import com.osglab.account.common.security.installSessionAuthentication
import com.osglab.account.config.AppConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.account.AccountRepository
import com.osglab.account.features.account.AccountReauthenticator
import com.osglab.account.features.account.AccountService
import com.osglab.account.features.account.AppleAccountReauthenticator
import com.osglab.account.features.account.AppleRevocationOutboxProcessor
import com.osglab.account.features.account.ExposedAccountRepository
import com.osglab.account.features.account.accountRoutes
import com.osglab.account.features.appleevents.AppleEventService
import com.osglab.account.features.appleevents.AppleEventRepository
import com.osglab.account.features.appleevents.AppleEventVerifier
import com.osglab.account.features.appleevents.ExposedAppleEventRepository
import com.osglab.account.features.appleevents.appleEventRoutes
import com.osglab.account.features.auth.AccountProvisioner
import com.osglab.account.features.auth.AppleIdentityTokenVerifier
import com.osglab.account.features.auth.AppleJwksProvider
import com.osglab.account.features.auth.AppleTokenClient
import com.osglab.account.features.auth.AuthRepository
import com.osglab.account.features.auth.ExposedAuthRepository
import com.osglab.account.features.auth.RemoteAppleJwksProvider
import com.osglab.account.features.auth.SessionService
import com.osglab.account.features.auth.SessionAccessAuthenticator
import com.osglab.account.features.auth.authRoutes
import com.osglab.account.features.auth.createAppleTokenClient
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.credits.repositories.ExposedBillingTransactionRunner
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.routes.creditRoutes
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.gateway.adapters.CreditReservationAdapter
import com.osglab.account.features.gateway.adapters.SessionIdentityAdapter
import com.osglab.account.features.gateway.GatewaySettings
import com.osglab.account.features.gateway.asr.AsrStreamingService
import com.osglab.account.features.gateway.ports.CreditReservationPort
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
import com.osglab.account.features.gateway.ports.GatewayGrantPort
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
import com.osglab.account.features.gateway.ports.GatewayIdentityPort
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import com.osglab.account.features.gateway.providers.deepseek.DeepSeekConfig
import com.osglab.account.features.gateway.providers.deepseek.DeepSeekProvider
import com.osglab.account.features.gateway.providers.volcengine.KtorVolcengineAsrTransport
import com.osglab.account.features.gateway.providers.volcengine.VolcengineAsrConfig
import com.osglab.account.features.gateway.providers.volcengine.VolcengineAsrProvider
import com.osglab.account.features.gateway.repositories.ExposedGatewayRepository
import com.osglab.account.features.gateway.routes.configureGatewayRoutes
import com.osglab.account.features.gateway.services.GatewayBearerIdentity
import com.osglab.account.features.gateway.services.GatewayGrantService
import com.osglab.account.features.gateway.services.GatewayReconciliationService
import com.osglab.account.features.gateway.services.GatewayService
import com.osglab.account.features.integrity.AppAttestCrypto
import com.osglab.account.features.integrity.AppAttestRepository
import com.osglab.account.features.integrity.AppAttestService
import com.osglab.account.features.integrity.AppAttestVerifier
import com.osglab.account.features.integrity.AppleDeviceCheckClient
import com.osglab.account.features.integrity.BundledAppleAppAttestTrust
import com.osglab.account.features.integrity.DeviceCheckTrialClaimRepository
import com.osglab.account.features.integrity.DeviceCheckTrialService
import com.osglab.account.features.integrity.DeviceCheckVerifier
import com.osglab.account.features.integrity.ExposedAppAttestRepository
import com.osglab.account.features.integrity.ExposedDeviceCheckTrialClaimRepository
import com.osglab.account.features.integrity.IntegrityService
import com.osglab.account.features.integrity.LibraryAppAttestCrypto
import com.osglab.account.features.integrity.MysqlDeviceCheckTrialMutex
import com.osglab.account.features.integrity.RemoteDeviceCheckVerifier
import com.osglab.account.features.integrity.TrialCreditGranter
import com.osglab.account.features.integrity.UnavailableAppleDeviceCheckClient
import com.osglab.account.features.integrity.createDeviceCheckClient
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.routes.referralRoutes
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 io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.websocket.WebSockets as ClientWebSockets
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationStopped
import io.ktor.server.application.install
import io.ktor.server.plugins.callid.CallId
import io.ktor.server.plugins.callid.callIdMdc
import io.ktor.server.plugins.calllogging.CallLogging
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.defaultheaders.DefaultHeaders
import io.ktor.server.plugins.forwardedheaders.XForwardedHeaders
import io.ktor.server.plugins.ratelimit.RateLimit
import io.ktor.server.plugins.ratelimit.RateLimitName
import io.ktor.server.plugins.ratelimit.rateLimit
import io.ktor.server.request.httpMethod
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.Route
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import kotlinx.serialization.json.Json
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.koin.core.module.Module
import org.koin.dsl.module
import org.koin.ktor.ext.getKoin
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
import java.time.Duration
import java.time.Instant
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.time.Duration.Companion.minutes
private val JSON = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
fun Application.module() {
val appConfig = AppConfig.from(environment.config)
install(ContentNegotiation) {
json(JSON)
}
install(DefaultHeaders) {
header("X-Content-Type-Options", "nosniff")
header("X-Frame-Options", "DENY")
header("Referrer-Policy", "no-referrer")
if (appConfig.isProduction) {
header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
}
install(CallId) {
retrieveFromHeader("X-Request-ID")
verify { it.matches(REQUEST_ID) }
generate { UUID.randomUUID().toString() }
}
install(CallLogging) {
callIdMdc("requestId")
// Never include paths, headers or bodies: they may contain invitation or session tokens.
format { call -> "${call.request.httpMethod.value} status=${call.response.status()}" }
}
install(WebSockets) {
maxFrameSize = 4L * 1024 * 1024
masking = false
}
install(XForwardedHeaders)
install(RateLimit) {
register(AUTH_RATE_LIMIT) {
rateLimiter(limit = 10, refillPeriod = 1.minutes)
}
register(ACCOUNT_RATE_LIMIT) {
rateLimiter(limit = 60, refillPeriod = 1.minutes)
}
register(GATEWAY_RATE_LIMIT) {
rateLimiter(limit = 120, refillPeriod = 1.minutes)
}
register(PUBLIC_RATE_LIMIT) {
rateLimiter(limit = 120, refillPeriod = 1.minutes)
}
}
installApiStatusPages()
install(Koin) {
slf4jLogger()
modules(accountServerModule(appConfig))
}
val koin = getKoin()
// Fail startup before accepting traffic if migrations or database connectivity fail.
koin.get<DatabaseFactory>().database
val sessionAuthenticator = koin.get<SessionAccessAuthenticator>()
installSessionAuthentication(sessionAuthenticator::authenticate)
val asrStreaming = if (appConfig.providers.volcengine.credentialsAvailable) {
val providerConfig = appConfig.providers.volcengine.toProviderConfig()
AsrStreamingService(
gateway = koin.get(),
upstream = KtorVolcengineAsrTransport(koin.get(), providerConfig),
scope = this,
)
} else {
null
}
launch {
while (isActive) {
try {
koin.get<AppleRevocationOutboxProcessor>().processPending()
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
// Durable outbox state is retried; never log sensitive token material.
}
try {
koin.get<GatewayReconciliationService>().reconcile()
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
// Durable settlement state is retried without logging provider data.
}
delay(60_000)
}
}
monitor.subscribe(ApplicationStopped) {
koin.get<HttpClient>().close()
koin.get<DatabaseFactory>().close()
}
routing {
healthRoutes(koin.get())
rateLimit(AUTH_RATE_LIMIT) {
authRoutes(koin.get())
}
rateLimit(ACCOUNT_RATE_LIMIT) {
accountRoutes(koin.get())
creditRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), koin.get())
}
rateLimit(GATEWAY_RATE_LIMIT) {
configureGatewayRoutes(
service = koin.get(),
appIdentity = koin.get<GatewayIdentityPort>(),
gatewayIdentity = koin.get<GatewayAccessTokenPort>(),
grantService = koin.get(),
asrStreaming = asrStreaming,
)
}
rateLimit(PUBLIC_RATE_LIMIT) {
appleEventRoutes(koin.get())
configureInviteWebRoutes(koin.get(), koin.get())
integrityRoutes(koin.get())
}
}
}
fun Route.healthRoutes(databaseFactory: DatabaseFactory? = null) {
get("/health") { call.respondText("""{"status":"UP"}""", ContentType.Application.Json) }
get("/health/live") { call.respondText("""{"status":"UP"}""", ContentType.Application.Json) }
get("/health/ready") {
if (databaseFactory?.isReady() == true) {
call.respondText("""{"status":"UP"}""", ContentType.Application.Json)
} else {
call.respondText(
"""{"status":"DOWN"}""",
ContentType.Application.Json,
HttpStatusCode.ServiceUnavailable,
)
}
}
}
fun accountServerModule(config: AppConfig): Module = module {
single { config }
single { DatabaseFactory(config.database) }
single { get<DatabaseFactory>().database }
single {
HttpClient(CIO) {
followRedirects = false
install(HttpTimeout) {
connectTimeoutMillis = 10_000
socketTimeoutMillis = 60_000
requestTimeoutMillis = 360_000
}
install(ClientContentNegotiation) {
json(JSON)
}
install(ClientWebSockets) {
maxFrameSize = 4L * 1024 * 1024
}
}
}
single { SessionJwt(config.session) }
single { FieldEncryptor(config.encryption.key) }
single { IdentityFingerprint(config.antiAbuse.identityHmacKey) }
single<AppleJwksProvider> {
RemoteAppleJwksProvider(get(), config.apple.jwksUrl)
}
single { AppleIdentityTokenVerifier(config.apple, get()) }
single<AppleTokenClient> { createAppleTokenClient(get(), config.apple) }
single<AppleDeviceCheckClient> {
createDeviceCheckClient(get(), config.apple, config.integrity.appleEnvironment)
?: UnavailableAppleDeviceCheckClient()
}
single<DeviceCheckVerifier> { RemoteDeviceCheckVerifier(get()) }
single<AppAttestRepository> { ExposedAppAttestRepository(get()) }
single<AppAttestCrypto> {
LibraryAppAttestCrypto(
config = config.integrity,
certificateValidator = BundledAppleAppAttestTrust.validator(),
)
}
single { AppAttestService(get(), get(), config.integrity) }
single<AppAttestVerifier> { get<AppAttestService>() }
single { IntegrityService(config.integrity, get(), get()) }
single<DeviceCheckTrialClaimRepository> { ExposedDeviceCheckTrialClaimRepository(get()) }
single { MysqlDeviceCheckTrialMutex(get()) }
single<AuthRepository> { ExposedAuthRepository(get()) }
single { SessionAccessAuthenticator(get(), get()) }
single<AccountRepository> { ExposedAccountRepository(get(), get()) }
single<AppleEventRepository> { ExposedAppleEventRepository(get(), get(), config.antiAbuse) }
single { AppleEventVerifier(config.apple, get()) }
single { AppleEventService(get(), get()) }
single<BillingTransactionRunner> {
ExposedBillingTransactionRunner(get())
}
single {
CreditService(
transactions = get(),
referralRewards = ReferralRewardConfig(
inviterCredits = config.credits.referralInviter,
inviteeCredits = config.credits.referralInvitee,
),
)
}
single<TrialCreditGranter> {
TrialCreditGranter { accountId ->
get<CreditService>().grantSignupTrial(
userId = accountId,
credits = config.credits.signupTrial,
idempotencyKey = "internal:signup-trial:$accountId",
)
}
}
single {
DeviceCheckTrialService(
repository = get(),
client = get(),
creditGranter = get(),
policy = config.integrity.deviceCheckPolicy,
mutex = get<MysqlDeviceCheckTrialMutex>(),
)
}
single { ExposedGatewayRepository(get()) }
single<GatewayGrantRepository> { get<ExposedGatewayRepository>() }
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
single<AccountProvisioner> {
AccountProvisioner { accountId, deviceCheckToken ->
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
if (deviceCheckToken != null && !granted) {
get<AuthRepository>().restrictAccountForAntiAbuse(
accountId,
java.time.Instant.now(),
)
}
}
}
single {
SessionService(
repository = get(),
appleIdentityVerifier = get(),
appleTokenClient = get(),
integrityService = get(),
sessionJwt = get(),
fieldEncryptor = get(),
identityFingerprint = get(),
sessionConfig = config.session,
accountProvisioner = get(),
)
}
single { AppleRevocationOutboxProcessor(get(), get(), get()) }
single<AccountReauthenticator> {
AppleAccountReauthenticator(
identityVerifier = get(),
appleTokenClient = get(),
identityFingerprint = get(),
)
}
single {
AccountService(
repository = get(),
fieldEncryptor = get(),
antiAbuseConfig = config.antiAbuse,
revocationProcessor = get(),
reauthenticator = get(),
)
}
single<UserRegistrationTimeProvider> {
UserRegistrationTimeProvider { accountId ->
get<AccountRepository>().findById(accountId)?.createdAt
}
}
single<ReferralRiskProvider> {
ReferralRiskProvider { accountId ->
get<AccountRepository>().findById(accountId)?.let {
ReferralRiskIdentity(it.identityFingerprint, it.antiAbuseRestricted)
}
}
}
single {
ReferralService(
transactions = get(),
registrationTimeProvider = get(),
riskProvider = get(),
bindingWindow = Duration.ofDays(config.credits.referralBindingDays),
)
}
single<ReferralLookupPort> {
val transactions = get<BillingTransactionRunner>()
ReferralLookupPort { code ->
transactions.inTransaction { unit ->
val referralCode = unit.referrals.findCode(code)
referralCode?.campaignId
?.let(unit.referrals::findCampaign)
?.isActive(Instant.now()) == true
}
}
}
single { SessionIdentityAdapter(get()) }
single<AuthenticatedUserExtractor> { get<SessionIdentityAdapter>() }
single<GatewayIdentityPort> { get<SessionIdentityAdapter>() }
single {
GatewaySettings(
issuer = config.session.issuer,
audience = "${config.session.audience}-gateway",
accessTokenHmacSecret = deriveGatewaySecret(config.session.hmacSecret, "gateway-access"),
refreshTokenHmacSecret = deriveGatewaySecret(config.session.hmacSecret, "gateway-refresh"),
accessTokenLifetime = Duration.ofMinutes(5),
refreshTokenLifetime = Duration.ofDays(config.session.refreshDays),
maximumGrantLifetime = Duration.ofDays(config.session.gatewayGrantDays),
)
}
single { GatewayGrantService(get(), get()) }
single<GatewayAccessTokenPort> { GatewayBearerIdentity(get()) }
single<CreditReservationPort> {
CreditReservationAdapter(
creditService = get(),
llmModel = config.providers.deepSeek.model,
asrModel = config.providers.volcengine.resourceId,
)
}
single {
ProviderCatalog(configuredProviders(config, get()))
}
single { GatewayService(get(), get(), get(), get()) }
single { GatewayReconciliationService(get(), get()) }
single {
InviteWebConfig(
appStoreUrl = config.appStoreUrl,
appleAppId = "${config.integrity.appAttestTeamId}.${config.integrity.appAttestBundleId}",
universalLinkBaseUrl = config.inviteBaseUrl,
)
}
}
private fun configuredProviders(config: AppConfig, client: HttpClient): List<GatewayProvider> =
buildList {
config.providers.deepSeek.apiKey?.let { apiKey ->
add(
DeepSeekProvider(
client,
DeepSeekConfig(
endpoint = config.providers.deepSeek.endpoint,
apiKey = apiKey,
model = config.providers.deepSeek.model,
),
),
)
}
if (config.providers.volcengine.credentialsAvailable) {
val providerConfig = config.providers.volcengine.toProviderConfig()
add(VolcengineAsrProvider(KtorVolcengineAsrTransport(client, providerConfig)))
}
}
private fun com.osglab.account.config.VolcengineConfig.toProviderConfig() =
VolcengineAsrConfig(
endpoint = endpoint,
resourceId = resourceId,
appId = appId,
accessToken = accessToken,
apiKey = apiKey,
)
private fun deriveGatewaySecret(master: ByteArray, context: String): ByteArray =
Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(master, "HmacSHA256"))
doFinal("osg-account-server:$context".toByteArray(Charsets.UTF_8))
}
private val REQUEST_ID = Regex("[A-Za-z0-9_-]{8,64}")
private val AUTH_RATE_LIMIT = RateLimitName("auth")
private val ACCOUNT_RATE_LIMIT = RateLimitName("account")
private val GATEWAY_RATE_LIMIT = RateLimitName("gateway")
private val PUBLIC_RATE_LIMIT = RateLimitName("public")
@@ -0,0 +1,59 @@
package com.osglab.account.common.api
import com.osglab.account.common.errors.ApiException
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.plugins.statuspages.exception
import io.ktor.server.response.respond
import io.ktor.util.AttributeKey
import kotlinx.serialization.Serializable
@Serializable
data class ApiResponse<T>(val data: T)
@Serializable
data class ApiErrorResponse(val error: ApiError)
@Serializable
data class ApiError(
val code: String,
val message: String,
)
fun Application.installApiStatusPages() {
install(StatusPages) {
status(HttpStatusCode.Unauthorized) { call, status ->
if (!call.attributes.contains(API_ERROR_HANDLED)) {
call.respond(
status,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
}
}
exception<ApiException> { call, cause ->
call.attributes.put(API_ERROR_HANDLED, true)
call.respond(
cause.status,
ApiErrorResponse(ApiError(cause.code, cause.message)),
)
}
exception<BadRequestException> { call, _ ->
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "Request body is invalid")),
)
}
exception<Throwable> { call, _ ->
call.respond(
HttpStatusCode.InternalServerError,
ApiErrorResponse(ApiError("internal_error", "An internal error occurred")),
)
}
}
}
private val API_ERROR_HANDLED = AttributeKey<Boolean>("api-error-handled")
@@ -0,0 +1,65 @@
package com.osglab.account.common.errors
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.plugins.statuspages.exception
import io.ktor.server.response.respond
import kotlinx.serialization.Serializable
@Serializable
data class ApiErrorResponse(val error: ApiError)
@Serializable
data class ApiError(
val code: String,
val message: String,
)
open class ApiException(
val status: HttpStatusCode,
val code: String,
override val message: String,
) : RuntimeException(message)
class InvalidRequestException(message: String) :
ApiException(HttpStatusCode.BadRequest, "invalid_request", message)
class UnauthorizedException(message: String = "Authentication required") :
ApiException(HttpStatusCode.Unauthorized, "unauthorized", message)
class TokenReuseException :
ApiException(
HttpStatusCode.Unauthorized,
"refresh_token_reuse",
"Refresh token reuse detected; the session family has been revoked",
)
class ExternalServiceUnavailableException(service: String) :
ApiException(
HttpStatusCode.ServiceUnavailable,
"external_service_unavailable",
"$service is temporarily unavailable",
)
class ConflictException(message: String) :
ApiException(HttpStatusCode.Conflict, "conflict", message)
fun Application.installApiErrors() {
install(StatusPages) {
exception<ApiException> { call, cause ->
call.respond(
cause.status,
ApiErrorResponse(ApiError(cause.code, cause.message)),
)
}
exception<Throwable> { call, _ ->
call.respond(
HttpStatusCode.InternalServerError,
ApiErrorResponse(ApiError("internal_error", "An internal error occurred")),
)
}
}
}
@@ -0,0 +1,61 @@
package com.osglab.account.common.security
import java.security.GeneralSecurityException
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class FieldEncryptor(
key: ByteArray,
private val secureRandom: SecureRandom = SecureRandom(),
) {
private val keySpec: SecretKeySpec
init {
require(key.size == AES_KEY_BYTES) { "AES-GCM requires a 32-byte key" }
keySpec = SecretKeySpec(key.copyOf(), "AES")
}
fun encrypt(plaintext: String, context: String): String {
require(context.isNotBlank()) { "Encryption context must not be blank" }
val iv = ByteArray(GCM_IV_BYTES).also(secureRandom::nextBytes)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(GCM_TAG_BITS, iv))
cipher.updateAAD(context.toByteArray(Charsets.UTF_8))
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val encoder = Base64.getUrlEncoder().withoutPadding()
return listOf(VERSION, encoder.encodeToString(iv), encoder.encodeToString(ciphertext))
.joinToString(".")
}
fun decrypt(value: String, context: String): String {
require(context.isNotBlank()) { "Encryption context must not be blank" }
val parts = value.split('.')
require(parts.size == 3 && parts[0] == VERSION) { "Unsupported encrypted field format" }
return try {
val decoder = Base64.getUrlDecoder()
val iv = decoder.decode(parts[1])
require(iv.size == GCM_IV_BYTES) { "Invalid AES-GCM IV" }
val ciphertext = decoder.decode(parts[2])
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, keySpec, GCMParameterSpec(GCM_TAG_BITS, iv))
cipher.updateAAD(context.toByteArray(Charsets.UTF_8))
cipher.doFinal(ciphertext).toString(Charsets.UTF_8)
} catch (exception: GeneralSecurityException) {
throw FieldDecryptionException(exception)
} catch (exception: IllegalArgumentException) {
throw FieldDecryptionException(exception)
}
}
}
class FieldDecryptionException(cause: Throwable) :
IllegalStateException("Encrypted field authentication failed", cause)
private const val VERSION = "v1"
private const val AES_KEY_BYTES = 32
private const val GCM_IV_BYTES = 12
private const val GCM_TAG_BITS = 128
private const val TRANSFORMATION = "AES/GCM/NoPadding"
@@ -0,0 +1,34 @@
package com.osglab.account.common.security
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Produces a non-reversible, deployment-specific identifier for anti-abuse history.
* The HMAC key must be independent from the field-encryption and session keys.
*/
class IdentityFingerprint(
key: ByteArray,
) {
private val keySpec: SecretKeySpec
init {
require(key.size >= MIN_KEY_BYTES) {
"Identity fingerprint HMAC key must contain at least $MIN_KEY_BYTES bytes"
}
keySpec = SecretKeySpec(key.copyOf(), HMAC_ALGORITHM)
}
fun ofAppleSubject(subject: String): String {
require(subject.isNotBlank()) { "Apple subject must not be blank" }
val mac = Mac.getInstance(HMAC_ALGORITHM)
mac.init(keySpec)
return mac.doFinal(subject.toByteArray(Charsets.UTF_8))
.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
}
private companion object {
const val HMAC_ALGORITHM = "HmacSHA256"
const val MIN_KEY_BYTES = 32
}
}
@@ -0,0 +1,116 @@
package com.osglab.account.common.security
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.JOSEObjectType
import com.nimbusds.jose.crypto.MACSigner
import com.nimbusds.jose.crypto.MACVerifier
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.SessionConfig
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.bearer
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Date
import java.util.UUID
data class IssuedAccessToken(
val value: String,
val expiresAt: Instant,
) {
override fun toString(): String = "IssuedAccessToken(value=[REDACTED], expiresAt=$expiresAt)"
}
data class AccountPrincipal(
val userId: UUID,
val sessionId: UUID,
) {
// Compatibility name used by the existing credits and gateway adapters.
val accountId: UUID
get() = userId
}
@Deprecated("Use AccountPrincipal", ReplaceWith("AccountPrincipal"))
typealias SessionPrincipal = AccountPrincipal
class SessionJwt(
private val config: SessionConfig,
private val clock: Clock = Clock.systemUTC(),
) {
init {
require(config.hmacSecret.size >= MIN_HMAC_BYTES) {
"Session HMAC secret must contain at least $MIN_HMAC_BYTES bytes"
}
}
fun issue(userId: UUID, sessionId: UUID): IssuedAccessToken {
val now = clock.instant()
val expiresAt = now.plus(Duration.ofMinutes(config.accessMinutes))
val tokenId = UUID.randomUUID()
val claims = JWTClaimsSet.Builder()
.issuer(config.issuer)
.audience(config.audience)
.subject(userId.toString())
.jwtID(tokenId.toString())
.issueTime(Date.from(now))
.notBeforeTime(Date.from(now.minusSeconds(CLOCK_SKEW_SECONDS)))
.expirationTime(Date.from(expiresAt))
.claim(CLAIM_TYPE, ACCESS_TOKEN_TYPE)
.claim(CLAIM_SESSION, sessionId.toString())
.build()
val jwt = SignedJWT(
JWSHeader.Builder(JWSAlgorithm.HS256).type(JOSEObjectType.JWT).build(),
claims,
)
jwt.sign(MACSigner(config.hmacSecret))
return IssuedAccessToken(jwt.serialize(), expiresAt)
}
fun verify(serialized: String): AccountPrincipal? = runCatching {
require(serialized.isNotBlank() && serialized.length <= MAX_ACCESS_TOKEN_LENGTH)
val jwt = SignedJWT.parse(serialized)
require(jwt.header.algorithm == JWSAlgorithm.HS256)
require(jwt.header.type == JOSEObjectType.JWT)
require(jwt.header.criticalParams.isNullOrEmpty())
require(jwt.verify(MACVerifier(config.hmacSecret)))
val claims = jwt.jwtClaimsSet
val now = clock.instant()
require(claims.issuer == config.issuer)
require(claims.audience == listOf(config.audience))
require(claims.getStringClaim(CLAIM_TYPE) == ACCESS_TOKEN_TYPE)
val expiresAt = requireNotNull(claims.expirationTime).toInstant()
val notBefore = requireNotNull(claims.notBeforeTime).toInstant()
val issuedAt = requireNotNull(claims.issueTime).toInstant()
require(expiresAt.isAfter(now.minusSeconds(CLOCK_SKEW_SECONDS)))
require(notBefore.isBefore(now.plusSeconds(CLOCK_SKEW_SECONDS)))
require(!issuedAt.isAfter(now.plusSeconds(CLOCK_SKEW_SECONDS)))
require(expiresAt.isAfter(issuedAt))
UUID.fromString(requireNotNull(claims.jwtid))
AccountPrincipal(
userId = UUID.fromString(claims.subject),
sessionId = UUID.fromString(claims.getStringClaim(CLAIM_SESSION)),
)
}.getOrNull()
}
fun Application.installSessionAuthentication(
authenticateToken: suspend (String) -> AccountPrincipal?,
) {
install(Authentication) {
bearer(SESSION_AUTH_NAME) {
authenticate { credential -> authenticateToken(credential.token) }
}
}
}
const val SESSION_AUTH_NAME = "session"
private const val CLAIM_TYPE = "typ"
private const val CLAIM_SESSION = "sid"
private const val ACCESS_TOKEN_TYPE = "access"
private const val MIN_HMAC_BYTES = 32
private const val MAX_ACCESS_TOKEN_LENGTH = 4_096
private const val CLOCK_SKEW_SECONDS = 30L
@@ -0,0 +1,40 @@
package com.osglab.account.common.security
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
typealias RefreshTokenGenerator = Sha256SecureTokenGenerator
fun interface SecureTokenGenerator {
fun newRefreshToken(): String
}
/**
* Generates a 256-bit opaque token. Callers persist only its SHA-256 digest.
*/
class Sha256SecureTokenGenerator(
private val secureRandom: SecureRandom = SecureRandom(),
) : SecureTokenGenerator {
override fun newRefreshToken(): String =
ByteArray(REFRESH_TOKEN_BYTES)
.also(secureRandom::nextBytes)
.let(Base64.getUrlEncoder().withoutPadding()::encodeToString)
private companion object {
const val REFRESH_TOKEN_BYTES = 32
}
}
object TokenHash {
fun sha256(token: String): String =
MessageDigest.getInstance("SHA-256")
.digest(token.toByteArray(Charsets.UTF_8))
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
fun matches(token: String, expectedHex: String): Boolean {
val actual = sha256(token).toByteArray(Charsets.US_ASCII)
val expected = expectedHex.lowercase().toByteArray(Charsets.US_ASCII)
return MessageDigest.isEqual(actual, expected)
}
}
@@ -0,0 +1,519 @@
package com.osglab.account.config
import io.ktor.server.config.ApplicationConfig
import java.net.URI
import java.util.Base64
data class AppConfig(
val environment: Environment,
val publicBaseUrl: String,
val inviteBaseUrl: String,
val appStoreUrl: String,
val database: DatabaseConfig,
val session: SessionConfig,
val encryption: EncryptionConfig,
val antiAbuse: AntiAbuseConfig,
val apple: AppleConfig,
val credits: CreditsConfig,
val providers: ProvidersConfig,
val integrity: IntegrityConfig,
) {
val isProduction: Boolean = environment == Environment.PRODUCTION
companion object {
fun from(config: ApplicationConfig): AppConfig {
val environment = Environment.parse(config.required("app.environment"))
val production = environment == Environment.PRODUCTION
val databaseUsername = config.required("app.database.username")
val databasePassword = config.secret("app.database.password", production)
val migrationUsername = if (production) {
config.required("app.database.migrationUsername")
} else {
config.optionalValue("app.database.migrationUsername") ?: databaseUsername
}
val migrationPassword = config.optionalSecret(
"app.database.migrationPassword",
production = production,
) ?: databasePassword
val database = DatabaseConfig(
jdbcUrl = config.required("app.database.jdbcUrl"),
username = databaseUsername,
password = databasePassword,
migrationUsername = migrationUsername,
migrationPassword = migrationPassword,
maximumPoolSize = config.positiveInt("app.database.maximumPoolSize"),
)
val session = SessionConfig(
issuer = config.required("app.session.issuer"),
audience = config.required("app.session.audience"),
hmacSecret = config.secret("app.session.secret", production).toByteArray(),
accessMinutes = config.positiveLong("app.session.accessMinutes"),
refreshDays = config.positiveLong("app.session.refreshDays"),
gatewayGrantDays = config.positiveLong("app.session.gatewayGrantDays", 30),
)
val encryption = EncryptionConfig(
key = config.base64Key("app.encryption.keyBase64", production),
)
val antiAbuse = AntiAbuseConfig(
identityHmacKey = config.base64Key("app.antiAbuse.identityHmacKeyBase64", production),
tombstoneRetentionDays = config.positiveLong(
"app.antiAbuse.tombstoneRetentionDays",
365,
),
)
val apple = AppleConfig(
teamId = config.optionalSecret("app.apple.teamId", production),
keyId = config.optionalSecret("app.apple.keyId", production),
clientId = config.required("app.apple.clientId"),
privateKeyPem = config.optionalSecret("app.apple.privateKeyPem", production)
?.replace("\\n", "\n"),
jwksUrl = config.httpsUrl("app.apple.jwksUrl", production),
tokenUrl = config.httpsUrl("app.apple.tokenUrl", production),
revokeUrl = config.httpsUrl("app.apple.revokeUrl", production),
)
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),
referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7),
)
val providers = ProvidersConfig(
volcengine = VolcengineConfig(
endpoint = config.valueOrDefault(
"app.providers.volcengine.endpoint",
"wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async",
),
appId = config.optionalValue("app.providers.volcengine.appId"),
accessToken = config.optionalValue("app.providers.volcengine.accessToken"),
apiKey = config.optionalValue("app.providers.volcengine.apiKey"),
resourceId = config.valueOrDefault(
"app.providers.volcengine.resourceId",
"volc.seedasr.sauc.duration",
),
),
deepSeek = DeepSeekConfig(
endpoint = config.valueOrDefault(
"app.providers.deepseek.endpoint",
"https://api.deepseek.com/v1",
),
apiKey = config.optionalValue("app.providers.deepseek.apiKey"),
model = config.valueOrDefault("app.providers.deepseek.model", "deepseek-v4-flash"),
),
)
val integrity = IntegrityConfig(
deviceCheckPolicy = IntegrityPolicy.fromEnforced(
config.boolean("app.integrity.enforceDeviceCheck"),
),
appAttestPolicy = IntegrityPolicy.fromEnforced(
config.boolean("app.integrity.enforceAppAttest"),
),
appleEnvironment = AppleServiceEnvironment.parse(
config.valueOrDefault(
"app.integrity.appleEnvironment",
if (production) "production" else "development",
),
),
challengeLifetimeSeconds = config.positiveLong(
"app.integrity.challengeLifetimeSeconds",
300,
),
)
require(session.hmacSecret.size >= MIN_HMAC_SECRET_BYTES) {
"app.session.secret must contain at least $MIN_HMAC_SECRET_BYTES bytes"
}
require(encryption.key.size == AES_256_KEY_BYTES) {
"app.encryption.keyBase64 must decode to exactly $AES_256_KEY_BYTES bytes"
}
require(antiAbuse.identityHmacKey.size >= MIN_HMAC_SECRET_BYTES) {
"app.antiAbuse.identityHmacKeyBase64 must decode to at least $MIN_HMAC_SECRET_BYTES bytes"
}
require(
!session.hmacSecret.contentEquals(encryption.key) &&
!session.hmacSecret.contentEquals(antiAbuse.identityHmacKey) &&
!encryption.key.contentEquals(antiAbuse.identityHmacKey)
) {
"Session, field-encryption, and identity-HMAC secrets must be distinct"
}
require(database.jdbcUrl.startsWith("jdbc:mysql:")) {
"app.database.jdbcUrl must use MySQL"
}
require(
!production ||
(!database.migrationUsername.isPlaceholder() &&
database.migrationUsername != database.username)
) {
"Production migration and runtime database users must be distinct"
}
require(!production || database.migrationPassword != database.password) {
"Production migration and runtime database passwords must be distinct"
}
require(database.maximumPoolSize in 1..100) {
"app.database.maximumPoolSize must be between 1 and 100"
}
require(session.accessMinutes in 1..60) {
"app.session.accessMinutes must be between 1 and 60"
}
require(session.refreshDays in 1..365) {
"app.session.refreshDays must be between 1 and 365"
}
require(!production || providers.volcengine.credentialsAvailable) {
"Production Volcengine credentials are missing"
}
require(!production || providers.deepSeek.credentialsAvailable) {
"Production DeepSeek credentials are missing"
}
if (production) {
requireExactProviderEndpoint(
providers.deepSeek.endpoint,
"https",
"api.deepseek.com",
"DeepSeek",
)
requireExactProviderEndpoint(
providers.volcengine.endpoint,
"wss",
"openspeech.bytedance.com",
"Volcengine",
)
}
require(integrity.challengeLifetimeSeconds in 30..600) {
"app.integrity.challengeLifetimeSeconds must be between 30 and 600 seconds"
}
require(!production || integrity.appleEnvironment == AppleServiceEnvironment.PRODUCTION) {
"Production must use the Apple production integrity environment"
}
require(
!production ||
(
integrity.deviceCheckPolicy == IntegrityPolicy.ENFORCE &&
integrity.appAttestPolicy == IntegrityPolicy.ENFORCE
)
) {
"Production must enforce both DeviceCheck and App Attest"
}
require(!production || apple.teamId == APP_ATTEST_TEAM_ID) {
"Production Apple team ID must be $APP_ATTEST_TEAM_ID"
}
require(!production || apple.clientId == APP_ATTEST_BUNDLE_ID) {
"Production Apple client ID must be $APP_ATTEST_BUNDLE_ID"
}
if (production) {
requireExactAppleEndpoint(apple.jwksUrl, "/auth/keys", "JWKS")
requireExactAppleEndpoint(apple.tokenUrl, "/auth/token", "token")
requireExactAppleEndpoint(apple.revokeUrl, "/auth/revoke", "revoke")
}
val publicBaseUrl = config.productionValueOrDefault(
"app.publicBaseUrl",
"http://localhost:8080",
production,
).validatedExternalUrl("app.publicBaseUrl", production)
val inviteBaseUrl = config.productionValueOrDefault(
"app.inviteBaseUrl",
"https://osglab.com/i",
production,
).validatedExternalUrl("app.inviteBaseUrl", production)
val appStoreUrl = config.productionValueOrDefault(
"app.appStoreUrl",
"https://apps.apple.com",
production,
).validatedExternalUrl("app.appStoreUrl", production)
if (production) {
requireExactExternalUrl(publicBaseUrl, "account.osglab.com", "", "PUBLIC_BASE_URL")
requireExactExternalUrl(inviteBaseUrl, "osglab.com", "/i", "INVITE_BASE_URL")
requireOfficialAppStoreUrl(appStoreUrl)
}
return AppConfig(
environment = environment,
publicBaseUrl = publicBaseUrl,
inviteBaseUrl = inviteBaseUrl,
appStoreUrl = appStoreUrl,
database = database,
session = session,
encryption = encryption,
antiAbuse = antiAbuse,
apple = apple,
credits = credits,
providers = providers,
integrity = integrity,
)
}
}
}
enum class Environment {
DEVELOPMENT,
TEST,
PRODUCTION;
companion object {
fun parse(value: String): Environment = entries.firstOrNull {
it.name.equals(value, ignoreCase = true)
} ?: throw ConfigValidationException("Unsupported app.environment: $value")
}
}
data class DatabaseConfig(
val jdbcUrl: String,
val username: String,
val password: String,
val maximumPoolSize: Int,
val migrationUsername: String = username,
val migrationPassword: String = password,
)
data class SessionConfig(
val issuer: String,
val audience: String,
val hmacSecret: ByteArray,
val accessMinutes: Long,
val refreshDays: Long,
val gatewayGrantDays: Long = 30,
)
data class EncryptionConfig(val key: ByteArray)
data class AntiAbuseConfig(
val identityHmacKey: ByteArray,
val tombstoneRetentionDays: Long,
)
data class AppleConfig(
val teamId: String?,
val keyId: String?,
val clientId: String,
val privateKeyPem: String?,
val jwksUrl: String,
val tokenUrl: String,
val revokeUrl: String,
) {
val clientCredentialsAvailable: Boolean
get() = teamId != null && keyId != null && privateKeyPem != null
}
data class CreditsConfig(
val signupTrial: Long,
val referralInviter: Long,
val referralInvitee: Long,
val referralBindingDays: Long,
)
data class ProvidersConfig(
val volcengine: VolcengineConfig,
val deepSeek: DeepSeekConfig,
)
data class VolcengineConfig(
val endpoint: String,
val appId: String?,
val accessToken: String?,
val apiKey: String?,
val resourceId: String,
) {
val credentialsAvailable: Boolean
get() = !apiKey.isNullOrBlank() || (!appId.isNullOrBlank() && !accessToken.isNullOrBlank())
}
data class DeepSeekConfig(
val endpoint: String,
val apiKey: String?,
val model: String,
) {
val credentialsAvailable: Boolean
get() = !apiKey.isNullOrBlank()
}
data class IntegrityConfig(
val deviceCheckPolicy: IntegrityPolicy,
val appAttestPolicy: IntegrityPolicy,
val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT,
val challengeLifetimeSeconds: Long = 300,
val appAttestTeamId: String = APP_ATTEST_TEAM_ID,
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
)
enum class IntegrityPolicy {
MONITOR,
ENFORCE;
companion object {
fun fromEnforced(enforced: Boolean): IntegrityPolicy = if (enforced) ENFORCE else MONITOR
}
}
enum class AppleServiceEnvironment {
DEVELOPMENT,
PRODUCTION;
companion object {
fun parse(value: String): AppleServiceEnvironment = entries.firstOrNull {
it.name.equals(value, ignoreCase = true)
} ?: throw ConfigValidationException("Unsupported Apple integrity environment: $value")
}
}
class ConfigValidationException(message: String, cause: Throwable? = null) :
IllegalStateException(message, cause)
private const val MIN_HMAC_SECRET_BYTES = 32
private const val AES_256_KEY_BYTES = 32
const val APP_ATTEST_TEAM_ID = "X329MZU23S"
const val APP_ATTEST_BUNDLE_ID = "com.osgkeyboard.ios"
private val PLACEHOLDER_MARKERS = listOf("replace-with", "change-me", "\${", "$")
private fun ApplicationConfig.required(path: String): String =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
?: throw ConfigValidationException("Missing required configuration: $path")
private fun ApplicationConfig.valueOrDefault(path: String, default: String): String =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty) ?: default
private fun ApplicationConfig.productionValueOrDefault(
path: String,
default: String,
production: Boolean,
): String = if (production) required(path) else valueOrDefault(path, default)
private fun ApplicationConfig.optionalValue(path: String): String? =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
?.takeUnless(String::isPlaceholder)
private fun ApplicationConfig.secret(path: String, production: Boolean): String =
optionalSecret(path, production)
?: throw ConfigValidationException("Missing required secret: $path")
private fun ApplicationConfig.optionalSecret(path: String, production: Boolean): String? {
val value = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
if (production && (value == null || value.isPlaceholder())) {
throw ConfigValidationException("Production secret is missing or uses a placeholder: $path")
}
return value?.takeUnless(String::isPlaceholder)
}
private fun String.isPlaceholder(): Boolean =
PLACEHOLDER_MARKERS.any { marker -> contains(marker, ignoreCase = true) }
private fun ApplicationConfig.positiveInt(path: String): Int =
required(path).toIntOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("$path must be a positive integer")
private fun ApplicationConfig.positiveLong(path: String): Long =
required(path).toLongOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("$path must be a positive integer")
private fun ApplicationConfig.positiveLong(path: String, default: Long): Long =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)?.let {
it.toLongOrNull()?.takeIf { value -> value > 0 }
?: throw ConfigValidationException("$path must be a positive integer")
} ?: default
private fun ApplicationConfig.boolean(path: String): Boolean =
required(path).let {
when (it.lowercase()) {
"true" -> true
"false" -> false
else -> throw ConfigValidationException("$path must be true or false")
}
}
private fun ApplicationConfig.base64Key(path: String, production: Boolean): ByteArray {
val encoded = secret(path, production)
return try {
Base64.getDecoder().decode(encoded)
} catch (exception: IllegalArgumentException) {
throw ConfigValidationException("$path must be valid Base64", exception)
}
}
private fun ApplicationConfig.httpsUrl(path: String, production: Boolean): String {
val value = required(path)
val uri = runCatching { URI(value) }
.getOrElse { throw ConfigValidationException("$path must be a valid URL", it) }
require(uri.isAbsolute && !uri.host.isNullOrBlank() && uri.userInfo == null) {
"$path must be an absolute URL without user information"
}
require(uri.scheme.equals("https", true) || (!production && uri.scheme.equals("http", true))) {
"$path must use HTTP or HTTPS"
}
require(!production || uri.scheme.equals("https", ignoreCase = true)) {
"$path must use HTTPS in production"
}
return value
}
private fun String.validatedExternalUrl(path: String, production: Boolean): String {
val uri = runCatching { URI(this) }
.getOrElse { throw ConfigValidationException("$path must be a valid URL", it) }
require(uri.isAbsolute && !uri.host.isNullOrBlank() && uri.userInfo == null) {
"$path must be an absolute URL without user information"
}
require(uri.scheme.equals("https", true) || (!production && uri.scheme.equals("http", true))) {
"$path must use HTTPS${if (production) "" else " or HTTP"}"
}
return this
}
private fun requireExactAppleEndpoint(value: String, path: String, name: String) {
val uri = URI(value)
require(
uri.scheme.equals("https", true) &&
uri.host.equals("appleid.apple.com", true) &&
uri.port == -1 &&
uri.path == path &&
uri.rawQuery == null &&
uri.rawFragment == null
) {
"Apple $name endpoint must be https://appleid.apple.com$path"
}
}
private fun requireExactProviderEndpoint(
value: String,
scheme: String,
host: String,
provider: String,
) {
val uri = runCatching { URI(value) }
.getOrElse { throw ConfigValidationException("$provider endpoint is invalid", it) }
require(
uri.scheme.equals(scheme, ignoreCase = true) &&
uri.host.equals(host, ignoreCase = true) &&
uri.userInfo == null &&
(uri.port == -1 || uri.port == 443)
) {
"$provider endpoint must use $scheme://$host on the default TLS port"
}
}
private fun requireExactExternalUrl(value: String, host: String, path: String, name: String) {
val uri = URI(value)
require(
uri.scheme.equals("https", ignoreCase = true) &&
uri.host.equals(host, ignoreCase = true) &&
uri.port == -1 &&
uri.path.trimEnd('/') == path &&
uri.rawQuery == null &&
uri.rawFragment == null
) {
"$name must be https://$host$path"
}
}
private fun requireOfficialAppStoreUrl(value: String) {
val uri = URI(value)
require(
uri.scheme.equals("https", ignoreCase = true) &&
uri.host.equals("apps.apple.com", ignoreCase = true) &&
uri.port == -1 &&
uri.userInfo == null &&
uri.rawFragment == null &&
APP_STORE_APP_PATH.matches(uri.path)
) {
"APP_STORE_URL must be an official apps.apple.com URL ending in a non-zero numeric App ID"
}
}
private val APP_STORE_APP_PATH = Regex("/.+/id[1-9][0-9]*")
@@ -0,0 +1,106 @@
package com.osglab.account.config
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import kotlinx.coroutines.Dispatchers
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction
import java.sql.DriverManager
class DatabaseFactory(
private val config: DatabaseConfig,
) : AutoCloseable {
private val dataSourceDelegate = lazy(::createDataSource)
private val dataSource: HikariDataSource by dataSourceDelegate
val database: Database by lazy {
Flyway.configure()
.dataSource(
config.jdbcUrl,
config.migrationUsername,
config.migrationPassword,
)
.validateMigrationNaming(true)
.load()
.migrate()
Database.connect(dataSource)
}
suspend fun <T> query(block: suspend () -> T): T =
kotlinx.coroutines.withContext(Dispatchers.IO) {
suspendTransaction(database) { block() }
}
suspend fun isReady(): Boolean = kotlinx.coroutines.withContext(Dispatchers.IO) {
runCatching {
// Initializing `database` also validates and applies Flyway migrations.
database
dataSource.connection.use { connection ->
connection.prepareStatement("SELECT 1").use { statement ->
statement.executeQuery().use { result ->
check(result.next() && result.getInt(1) == 1)
}
}
}
}.isSuccess
}
/**
* Uses a dedicated physical connection because MySQL named locks are
* connection-scoped. The protected block may use normal repository
* transactions without exhausting the Hikari pool.
*/
suspend fun <T> withMysqlNamedLock(
name: String,
timeoutSeconds: Int,
block: suspend () -> T,
): T = kotlinx.coroutines.withContext(Dispatchers.IO) {
require(name.isNotBlank() && name.length <= 64)
require(timeoutSeconds in 1..60)
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
val acquired = connection.prepareStatement("SELECT GET_LOCK(?, ?)").use { statement ->
statement.setString(1, name)
statement.setInt(2, timeoutSeconds)
statement.executeQuery().use { result ->
result.next() && result.getInt(1) == 1
}
}
if (!acquired) throw IllegalStateException("Timed out acquiring database named lock")
try {
block()
} finally {
runCatching {
connection.prepareStatement("SELECT RELEASE_LOCK(?)").use { statement ->
statement.setString(1, name)
statement.executeQuery().close()
}
}
}
}
}
override fun close() {
if (dataSourceDelegate.isInitialized()) {
dataSourceDelegate.value.close()
}
}
private fun createDataSource(): HikariDataSource = HikariDataSource(
HikariConfig().apply {
jdbcUrl = config.jdbcUrl
username = config.username
password = config.password
maximumPoolSize = config.maximumPoolSize
minimumIdle = 1
connectionTimeout = 10_000
validationTimeout = 5_000
idleTimeout = 600_000
maxLifetime = 1_800_000
isAutoCommit = false
transactionIsolation = "TRANSACTION_READ_COMMITTED"
connectionInitSql = "SET time_zone = '+00:00'"
poolName = "osg-account-db"
},
)
}
@@ -0,0 +1,57 @@
package com.osglab.account.config
import com.osglab.account.common.api.installApiStatusPages
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.calllogging.CallLogging
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.defaultheaders.DefaultHeaders
import io.ktor.server.plugins.forwardedheaders.ForwardedHeaders
import io.ktor.server.plugins.forwardedheaders.XForwardedHeaders
import io.ktor.server.request.header
import io.ktor.server.request.httpMethod
import io.ktor.server.request.path
import io.ktor.server.websocket.WebSockets
import kotlinx.serialization.json.Json
import org.slf4j.event.Level
fun Application.configureHttpPlugins() {
install(ForwardedHeaders)
install(XForwardedHeaders)
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = false
explicitNulls = false
encodeDefaults = true
},
)
}
install(DefaultHeaders) {
header("X-Content-Type-Options", "nosniff")
header("X-Frame-Options", "DENY")
header("Referrer-Policy", "no-referrer")
}
install(WebSockets) {
maxFrameSize = 1L * 1024 * 1024
masking = false
}
install(CallLogging) {
level = Level.INFO
filter { call -> !call.request.path().startsWith("/health/") }
mdc("requestId") { call -> call.request.header(REQUEST_ID_HEADER) ?: "generated" }
format { call ->
// Deliberately excludes query strings, headers and bodies.
"${call.request.httpMethod.value} ${call.request.path()} ${call.response.status()?.value ?: 0}"
}
}
installApiStatusPages()
}
private const val REQUEST_ID_HEADER = "X-Request-ID"
@@ -0,0 +1,212 @@
package com.osglab.account.features.account
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.features.auth.AccountIdentityTombstonesTable
import com.osglab.account.features.auth.AccountsTable
import com.osglab.account.features.auth.AppleCredentialsTable
import com.osglab.account.features.auth.withAppleIdentityLock
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.lessEq
import org.jetbrains.exposed.v1.core.plus
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.deleteWhere
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 java.time.Instant
import java.util.UUID
data class AccountRecord(
val id: UUID,
val identityFingerprint: String,
val antiAbuseRestricted: Boolean,
val encryptedAppleRefreshToken: String?,
val createdAt: Instant,
) {
override fun toString(): String =
"AccountRecord(id=$id, identityFingerprint=[REDACTED], " +
"antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " +
"createdAt=$createdAt)"
}
data class AppleRevocationOutboxRecord(
val id: UUID,
val encryptedRefreshToken: String,
val attemptCount: Int,
) {
override fun toString(): String =
"AppleRevocationOutboxRecord(id=$id, encryptedRefreshToken=[REDACTED], attemptCount=$attemptCount)"
}
data class NewAppleRevocation(
val id: UUID,
val encryptedRefreshToken: String,
) {
override fun toString(): String =
"NewAppleRevocation(id=$id, encryptedRefreshToken=[REDACTED])"
}
internal object AppleRevocationOutboxTable : Table("apple_revocation_outbox") {
val id = varchar("id", 36)
val encryptedRefreshToken = text("encrypted_refresh_token").nullable()
val createdAt = timestamp("created_at")
val nextAttemptAt = timestamp("next_attempt_at")
val attemptCount = integer("attempt_count")
val completedAt = timestamp("completed_at").nullable()
override val primaryKey = PrimaryKey(id)
}
interface AccountRepository {
suspend fun findById(accountId: UUID): AccountRecord?
suspend fun deleteById(
accountId: UUID,
deletedAt: Instant,
tombstoneExpiresAt: Instant,
createRevocation: (encryptedRefreshToken: String?) -> NewAppleRevocation?,
): Boolean
suspend fun pendingAppleRevocations(now: Instant, limit: Int): List<AppleRevocationOutboxRecord>
suspend fun rescheduleAppleRevocation(id: UUID, nextAttemptAt: Instant)
suspend fun completeAppleRevocation(id: UUID, completedAt: Instant)
}
class ExposedAccountRepository(
private val databaseFactory: DatabaseFactory,
@Suppress("UNUSED_PARAMETER") identityFingerprint: IdentityFingerprint,
) : AccountRepository {
override suspend fun findById(accountId: UUID): AccountRecord? = databaseFactory.query {
val row = AccountsTable.selectAll()
.where { AccountsTable.id eq accountId.toString() }
.singleOrNull()
?: return@query null
val fingerprint = row[AccountsTable.identityFingerprint] ?: return@query null
val encryptedRefreshToken = AppleCredentialsTable.selectAll()
.where { AppleCredentialsTable.accountId eq accountId.toString() }
.singleOrNull()
?.get(AppleCredentialsTable.encryptedRefreshToken)
row.let {
AccountRecord(
id = UUID.fromString(it[AccountsTable.id]),
identityFingerprint = fingerprint,
antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted],
encryptedAppleRefreshToken = encryptedRefreshToken,
createdAt = it[AccountsTable.createdAt],
)
}
}
override suspend fun deleteById(
accountId: UUID,
deletedAt: Instant,
tombstoneExpiresAt: Instant,
createRevocation: (encryptedRefreshToken: String?) -> NewAppleRevocation?,
): Boolean {
val fingerprint = databaseFactory.query {
AccountsTable.selectAll()
.where { AccountsTable.id eq accountId.toString() }
.singleOrNull()
?.get(AccountsTable.identityFingerprint)
} ?: return false
return databaseFactory.withAppleIdentityLock(fingerprint) {
databaseFactory.query {
val account = AccountsTable.selectAll()
.where { AccountsTable.id eq accountId.toString() }
.forUpdate()
.singleOrNull()
?: return@query false
val currentFingerprint = requireNotNull(account[AccountsTable.identityFingerprint])
check(currentFingerprint == fingerprint) { "Account identity changed unexpectedly" }
val encryptedRefreshToken = AppleCredentialsTable.selectAll()
.where { AppleCredentialsTable.accountId eq accountId.toString() }
.forUpdate()
.singleOrNull()
?.get(AppleCredentialsTable.encryptedRefreshToken)
recordIdentityTombstone(
currentFingerprint,
deletedAt,
tombstoneExpiresAt,
)
createRevocation(encryptedRefreshToken)?.let { pending ->
AppleRevocationOutboxTable.insert {
it[id] = pending.id.toString()
it[AppleRevocationOutboxTable.encryptedRefreshToken] = pending.encryptedRefreshToken
it[createdAt] = deletedAt
it[nextAttemptAt] = deletedAt
it[attemptCount] = 0
it[completedAt] = null
}
}
AccountsTable.deleteWhere { AccountsTable.id eq accountId.toString() } > 0
}
}
}
override suspend fun pendingAppleRevocations(
now: Instant,
limit: Int,
): List<AppleRevocationOutboxRecord> = databaseFactory.query {
AppleRevocationOutboxTable.selectAll()
.where {
AppleRevocationOutboxTable.completedAt.isNull() and
(AppleRevocationOutboxTable.nextAttemptAt lessEq now)
}
.orderBy(AppleRevocationOutboxTable.createdAt)
.limit(limit)
.map {
AppleRevocationOutboxRecord(
id = UUID.fromString(it[AppleRevocationOutboxTable.id]),
encryptedRefreshToken = requireNotNull(
it[AppleRevocationOutboxTable.encryptedRefreshToken],
),
attemptCount = it[AppleRevocationOutboxTable.attemptCount],
)
}
}
override suspend fun rescheduleAppleRevocation(id: UUID, nextAttemptAt: Instant) {
databaseFactory.query {
AppleRevocationOutboxTable.update({
(AppleRevocationOutboxTable.id eq id.toString()) and
AppleRevocationOutboxTable.completedAt.isNull()
}) {
it[attemptCount] = AppleRevocationOutboxTable.attemptCount + 1
it[AppleRevocationOutboxTable.nextAttemptAt] = nextAttemptAt
}
}
}
override suspend fun completeAppleRevocation(id: UUID, completedAt: Instant) {
databaseFactory.query {
AppleRevocationOutboxTable.update({
(AppleRevocationOutboxTable.id eq id.toString()) and
AppleRevocationOutboxTable.completedAt.isNull()
}) {
it[encryptedRefreshToken] = null
it[AppleRevocationOutboxTable.completedAt] = completedAt
}
}
}
}
internal fun recordIdentityTombstone(
identityFingerprint: String,
deletedAt: Instant,
expiresAt: Instant,
) {
AccountIdentityTombstonesTable.insertIgnore {
it[AccountIdentityTombstonesTable.identityFingerprint] = identityFingerprint
it[AccountIdentityTombstonesTable.deletedAt] = deletedAt
it[AccountIdentityTombstonesTable.expiresAt] = expiresAt
}
AccountIdentityTombstonesTable.update({
AccountIdentityTombstonesTable.identityFingerprint eq identityFingerprint
}) {
it[AccountIdentityTombstonesTable.deletedAt] = deletedAt
it[AccountIdentityTombstonesTable.expiresAt] = expiresAt
}
}
@@ -0,0 +1,77 @@
package com.osglab.account.features.account
import com.osglab.account.common.api.ApiResponse
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.SESSION_AUTH_NAME
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.request.receive
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.route
import kotlinx.serialization.Serializable
@Serializable
data class AccountResponse(
val id: String,
val createdAtEpochSeconds: Long,
)
@Serializable
data class DeleteAccountRequest(
val identityToken: String,
val authorizationCode: String,
val nonce: String,
) {
fun toProof() = AppleReauthenticationProof(
identityToken = identityToken,
authorizationCode = authorizationCode,
nonce = nonce,
)
override fun toString(): String =
"DeleteAccountRequest(identityToken=[REDACTED], " +
"authorizationCode=[REDACTED], nonce=[REDACTED])"
}
class AccountRoutes(
private val accountService: AccountService,
) {
fun register(parent: Route) {
with(parent) {
authenticate(SESSION_AUTH_NAME) {
route("/v1/account") {
get {
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,
),
),
)
}
delete {
val principal = call.principal<AccountPrincipal>()
?: throw UnauthorizedException()
accountService.delete(
principal.userId,
call.receive<DeleteAccountRequest>().toProof(),
)
call.respond(HttpStatusCode.NoContent)
}
}
}
}
}
}
fun Route.accountRoutes(accountService: AccountService) =
AccountRoutes(accountService).register(this)
@@ -0,0 +1,177 @@
package com.osglab.account.features.account
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.FieldDecryptionException
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.config.AntiAbuseConfig
import com.osglab.account.features.auth.AppleClientUnavailableException
import com.osglab.account.features.auth.AppleIdentityTokenVerifier
import com.osglab.account.features.auth.AppleTokenEndpointException
import com.osglab.account.features.auth.AppleTokenClient
import com.osglab.account.features.auth.AppleTokenInvalidException
import com.osglab.account.features.auth.AppleVerificationUnavailableException
import kotlinx.coroutines.CancellationException
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.UUID
data class AccountView(
val id: UUID,
val createdAt: Instant,
)
data class AppleReauthenticationProof(
val identityToken: String,
val authorizationCode: String,
val nonce: String,
) {
override fun toString(): String =
"AppleReauthenticationProof(identityToken=[REDACTED], " +
"authorizationCode=[REDACTED], nonce=[REDACTED])"
}
fun interface AccountReauthenticator {
/**
* Verifies recent Apple credentials and returns the newly issued Apple
* refresh token so account deletion can revoke the current authorization.
*/
suspend fun verify(account: AccountRecord, proof: AppleReauthenticationProof): String
}
class AppleAccountReauthenticator(
private val identityVerifier: AppleIdentityTokenVerifier,
private val appleTokenClient: AppleTokenClient,
private val identityFingerprint: IdentityFingerprint,
) : AccountReauthenticator {
override suspend fun verify(
account: AccountRecord,
proof: AppleReauthenticationProof,
): String {
requireProofValue(proof.identityToken, "identityToken", 16_384)
requireProofValue(proof.authorizationCode, "authorizationCode", 4_096)
requireProofValue(proof.nonce, "nonce", 512)
val supplied = verifyIdentity(proof.identityToken, proof.nonce)
val exchanged = try {
appleTokenClient.exchangeAuthorizationCode(proof.authorizationCode)
} catch (exception: AppleClientUnavailableException) {
throw ExternalServiceUnavailableException("Apple reauthentication")
} catch (exception: AppleTokenEndpointException) {
if (exception.retryable) throw ExternalServiceUnavailableException("Apple reauthentication")
throw UnauthorizedException("Apple reauthentication failed")
}
val confirmed = verifyIdentity(exchanged.identityToken, proof.nonce)
val fingerprint = identityFingerprint.ofAppleSubject(supplied.subject)
if (supplied.subject != confirmed.subject || fingerprint != account.identityFingerprint) {
throw UnauthorizedException("Apple reauthentication does not match this account")
}
return exchanged.refreshToken
}
private suspend fun verifyIdentity(token: String, nonce: String) =
try {
identityVerifier.verify(token, nonce)
} catch (exception: AppleVerificationUnavailableException) {
throw ExternalServiceUnavailableException("Apple reauthentication")
} catch (exception: AppleTokenInvalidException) {
throw UnauthorizedException("Apple reauthentication failed")
}
private fun requireProofValue(value: String, name: String, maximumLength: Int) {
if (value.isBlank() || value.length > maximumLength) {
throw UnauthorizedException("Apple $name is invalid")
}
}
}
class AccountService(
private val repository: AccountRepository,
private val fieldEncryptor: FieldEncryptor,
private val antiAbuseConfig: AntiAbuseConfig,
private val revocationProcessor: AppleRevocationOutboxProcessor,
private val reauthenticator: AccountReauthenticator,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun get(accountId: UUID): AccountView {
val account = repository.findById(accountId) ?: throw UnauthorizedException()
return AccountView(account.id, account.createdAt)
}
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
val account = repository.findById(accountId) ?: return
val now = clock.instant()
val currentRefreshToken = reauthenticator.verify(account, proof)
val revocationId = UUID.randomUUID()
val revocation = NewAppleRevocation(
id = revocationId,
encryptedRefreshToken = fieldEncryptor.encrypt(
currentRefreshToken,
appleRevocationContext(revocationId),
),
)
repository.deleteById(
accountId = accountId,
deletedAt = now,
tombstoneExpiresAt = now.plus(Duration.ofDays(antiAbuseConfig.tombstoneRetentionDays)),
createRevocation = { revocation },
)
try {
revocationProcessor.processPending(limit = 1)
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
// Local deletion is final. The durable outbox retry loop handles Apple outages.
}
}
}
class AppleRevocationOutboxProcessor(
private val repository: AccountRepository,
private val appleTokenClient: AppleTokenClient,
private val fieldEncryptor: FieldEncryptor,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun processPending(limit: Int = 20) {
require(limit > 0)
repository.pendingAppleRevocations(clock.instant(), limit).forEach { record ->
try {
val refreshToken = fieldEncryptor.decrypt(
record.encryptedRefreshToken,
appleRevocationContext(record.id),
)
appleTokenClient.revokeRefreshToken(refreshToken)
repository.completeAppleRevocation(record.id, clock.instant())
} catch (exception: CancellationException) {
throw exception
} catch (_: AppleClientUnavailableException) {
reschedule(record)
} catch (_: AppleTokenEndpointException) {
reschedule(record)
} catch (_: FieldDecryptionException) {
// Keep the ciphertext for recovery after a key/configuration correction,
// but do not let one poisoned record starve the rest of the batch.
reschedule(record)
}
}
}
private suspend fun reschedule(record: AppleRevocationOutboxRecord) {
val exponent = record.attemptCount.coerceIn(0, MAX_BACKOFF_EXPONENT)
val delaySeconds = BASE_BACKOFF_SECONDS * (1L shl exponent)
repository.rescheduleAppleRevocation(
record.id,
clock.instant().plusSeconds(delaySeconds.coerceAtMost(MAX_BACKOFF_SECONDS)),
)
}
private companion object {
const val BASE_BACKOFF_SECONDS = 30L
const val MAX_BACKOFF_SECONDS = 6 * 60 * 60L
const val MAX_BACKOFF_EXPONENT = 10
}
}
fun appleRevocationContext(id: UUID): String = "apple-revocation-outbox:$id"
@@ -0,0 +1,69 @@
package com.osglab.account.features.appleevents
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.config.AntiAbuseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.account.recordIdentityTombstone
import com.osglab.account.features.auth.AccountsTable
import com.osglab.account.features.auth.withAppleIdentityLock
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import java.time.Duration
import java.time.Instant
internal object AppleEventReceiptsTable : Table("apple_event_receipts") {
val eventId = varchar("event_id", 255)
val eventType = varchar("event_type", 64)
val receivedAt = timestamp("received_at")
override val primaryKey = PrimaryKey(eventId)
}
interface AppleEventRepository {
suspend fun apply(event: VerifiedAppleEvent, receivedAt: Instant): Boolean
}
class ExposedAppleEventRepository(
private val databaseFactory: DatabaseFactory,
private val identityFingerprint: IdentityFingerprint,
private val antiAbuseConfig: AntiAbuseConfig,
) : AppleEventRepository {
override suspend fun apply(event: VerifiedAppleEvent, receivedAt: Instant): Boolean {
if (!isAccountTerminatingAppleEvent(event.type)) {
return databaseFactory.query { insertReceipt(event, receivedAt) }
}
val fingerprint = identityFingerprint.ofAppleSubject(event.appleSubject)
return databaseFactory.withAppleIdentityLock(fingerprint) {
databaseFactory.query {
val inserted = insertReceipt(event, receivedAt)
if (inserted) {
recordIdentityTombstone(
fingerprint,
receivedAt,
receivedAt.plus(Duration.ofDays(antiAbuseConfig.tombstoneRetentionDays)),
)
AccountsTable.deleteWhere { AccountsTable.identityFingerprint eq fingerprint }
}
inserted
}
}
}
private fun insertReceipt(event: VerifiedAppleEvent, receivedAt: Instant): Boolean =
AppleEventReceiptsTable.insertIgnore {
it[AppleEventReceiptsTable.eventId] = event.eventId
it[AppleEventReceiptsTable.eventType] = event.type
it[AppleEventReceiptsTable.receivedAt] = receivedAt
}.insertedCount > 0
}
private val ACCOUNT_TERMINATING_EVENTS = setOf(
"consent-revoked",
"account-delete",
"account-deleted",
)
internal fun isAccountTerminatingAppleEvent(type: String): Boolean =
type in ACCOUNT_TERMINATING_EVENTS
@@ -0,0 +1,50 @@
package com.osglab.account.features.appleevents
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.features.auth.AppleVerificationUnavailableException
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.post
import kotlinx.serialization.Serializable
import java.time.Clock
@Serializable
data class AppleEventRequest(val payload: String) {
override fun toString(): String = "AppleEventRequest(payload=[REDACTED])"
}
class AppleEventService(
private val verifier: AppleEventVerifier,
private val repository: AppleEventRepository,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun receive(signedPayload: String) {
val event = try {
verifier.verify(signedPayload)
} catch (exception: AppleVerificationUnavailableException) {
throw ExternalServiceUnavailableException("Apple event verification")
} catch (exception: InvalidAppleEventException) {
throw UnauthorizedException("Apple event signature is invalid")
}
repository.apply(event, clock.instant())
}
}
class AppleEventRoutes(
private val service: AppleEventService,
) {
fun register(parent: Route) {
with(parent) {
post("/v1/apple/events") {
service.receive(call.receive<AppleEventRequest>().payload)
call.respond(HttpStatusCode.NoContent)
}
}
}
}
fun Route.appleEventRoutes(service: AppleEventService) =
AppleEventRoutes(service).register(this)
@@ -0,0 +1,99 @@
package com.osglab.account.features.appleevents
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.crypto.RSASSAVerifier
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import com.osglab.account.features.auth.AppleJwksProvider
import com.osglab.account.features.auth.isSuitableAppleSigningKey
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.time.Clock
import java.time.Duration
data class VerifiedAppleEvent(
val eventId: String,
val type: String,
val appleSubject: String,
) {
override fun toString(): String =
"VerifiedAppleEvent(eventId=$eventId, type=$type, appleSubject=[REDACTED])"
}
class AppleEventVerifier(
private val config: AppleConfig,
private val jwksProvider: AppleJwksProvider,
private val clock: Clock = Clock.systemUTC(),
private val json: Json = Json,
) {
suspend fun verify(signedPayload: String): VerifiedAppleEvent {
if (signedPayload.isBlank() || signedPayload.length > MAX_SIGNED_PAYLOAD_LENGTH) {
throw InvalidAppleEventException("Apple event has an invalid size")
}
val jwt = runCatching { SignedJWT.parse(signedPayload) }
.getOrElse { throw InvalidAppleEventException("Malformed Apple event", it) }
if (jwt.header.algorithm != JWSAlgorithm.RS256) {
throw InvalidAppleEventException("Apple event must use RS256")
}
val keyId = jwt.header.keyID?.takeIf(String::isNotBlank)
?: throw InvalidAppleEventException("Apple event is missing kid")
val key = jwksProvider.rsaKey(keyId)
?: throw InvalidAppleEventException("Apple event used an unknown key")
if (!key.isSuitableAppleSigningKey(keyId)) {
throw InvalidAppleEventException("Apple event used an unsuitable key")
}
if (!runCatching { jwt.verify(RSASSAVerifier(key.toRSAPublicKey())) }.getOrDefault(false)) {
throw InvalidAppleEventException("Apple event signature is invalid")
}
val claims = runCatching { jwt.jwtClaimsSet }
.getOrElse { throw InvalidAppleEventException("Apple event claims are invalid", it) }
val now = clock.instant()
if (claims.issuer != APPLE_ISSUER || claims.audience != listOf(config.clientId)) {
throw InvalidAppleEventException("Apple event issuer or audience is invalid")
}
val expiresAt = claims.expirationTime?.toInstant()
if (expiresAt?.isAfter(now.minus(CLOCK_SKEW)) != true) {
throw InvalidAppleEventException("Apple event has expired")
}
val issuedAt = claims.issueTime?.toInstant()
?: throw InvalidAppleEventException("Apple event is missing iat")
if (issuedAt.isAfter(now.plus(CLOCK_SKEW)) ||
issuedAt.isBefore(now.minus(MAX_EVENT_AGE)) ||
!expiresAt.isAfter(issuedAt)
) {
throw InvalidAppleEventException("Apple event is outside the accepted time window")
}
val eventId = claims.jwtid?.takeIf { it.isNotBlank() && it.length <= MAX_EVENT_ID_LENGTH }
?: throw InvalidAppleEventException("Apple event is missing jti")
val rawEvents = runCatching { claims.getStringClaim(EVENTS_CLAIM) }
.getOrElse { throw InvalidAppleEventException("Apple events claim is invalid", it) }
?.takeIf { it.isNotBlank() && it.length <= MAX_EVENTS_CLAIM_LENGTH }
?: throw InvalidAppleEventException("Apple event is missing or oversized events")
val events = runCatching { json.parseToJsonElement(rawEvents).jsonObject }
.getOrElse { throw InvalidAppleEventException("Apple events claim is invalid", it) }
val type = runCatching { events["type"]?.jsonPrimitive?.content }
.getOrElse { throw InvalidAppleEventException("Apple event type is invalid", it) }
?.takeIf { it.isNotBlank() && it.length <= MAX_EVENT_TYPE_LENGTH }
?: throw InvalidAppleEventException("Apple event type is missing")
val subject = runCatching { events["sub"]?.jsonPrimitive?.content }
.getOrElse { throw InvalidAppleEventException("Apple event subject is invalid", it) }
?.takeIf { it.isNotBlank() && it.length <= MAX_SUBJECT_LENGTH }
?: throw InvalidAppleEventException("Apple event subject is missing")
return VerifiedAppleEvent(eventId, type, subject)
}
}
class InvalidAppleEventException(message: String, cause: Throwable? = null) :
SecurityException(message, cause)
private const val APPLE_ISSUER = "https://appleid.apple.com"
private const val EVENTS_CLAIM = "events"
private const val MAX_EVENT_ID_LENGTH = 255
private const val MAX_EVENT_TYPE_LENGTH = 64
private const val MAX_SUBJECT_LENGTH = 128
private const val MAX_SIGNED_PAYLOAD_LENGTH = 16_384
private const val MAX_EVENTS_CLAIM_LENGTH = 4_096
private val MAX_EVENT_AGE: Duration = Duration.ofHours(24)
private val CLOCK_SKEW: Duration = Duration.ofSeconds(30)
@@ -0,0 +1,191 @@
package com.osglab.account.features.auth
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.crypto.RSASSAVerifier
import com.nimbusds.jose.jwk.JWKSet
import com.nimbusds.jose.jwk.KeyOperation
import com.nimbusds.jose.jwk.KeyUse
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.math.BigInteger
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
data class AppleIdentity(
val subject: String,
) {
override fun toString(): String = "AppleIdentity(subject=[REDACTED])"
}
interface AppleJwksProvider {
suspend fun rsaKey(keyId: String): RSAKey?
}
class RemoteAppleJwksProvider(
private val httpClient: HttpClient,
private val jwksUrl: String,
private val clock: Clock = Clock.systemUTC(),
private val cacheTtl: Duration = Duration.ofHours(6),
) : AppleJwksProvider {
private val mutex = Mutex()
private var cached: CachedJwks? = null
init {
require(!cacheTtl.isZero && !cacheTtl.isNegative && cacheTtl <= MAX_JWKS_CACHE_TTL) {
"Apple JWKS cache TTL must be between zero and 24 hours"
}
}
override suspend fun rsaKey(keyId: String): RSAKey? = mutex.withLock {
require(keyId.isNotBlank()) { "Apple key ID must not be blank" }
val nowMillis = clock.millis()
val current = cached
if (current != null && current.expiresAtMillis > nowMillis) {
val cachedKey = current.keys.getKeyByKeyId(keyId) as? RSAKey
if (cachedKey != null) return@withLock cachedKey
if (nowMillis - current.fetchedAtMillis < KEY_MISS_REFRESH_INTERVAL.toMillis()) {
return@withLock null
}
}
val response = runCatching { httpClient.get(jwksUrl) }
.getOrElse { throw AppleVerificationUnavailableException("Apple JWKS request failed", it) }
if (!response.status.isSuccess()) {
throw AppleVerificationUnavailableException("Apple JWKS returned HTTP ${response.status.value}")
}
val keys = runCatching { JWKSet.parse(response.bodyAsText()) }
.getOrElse { throw AppleVerificationUnavailableException("Apple JWKS response was invalid", it) }
val keyIds = keys.keys.map { it.keyID }
if (keys.keys.isEmpty() ||
keys.keys.size > MAX_JWK_COUNT ||
keyIds.any { it.isNullOrBlank() } ||
keyIds.distinct().size != keyIds.size
) {
throw AppleVerificationUnavailableException("Apple JWKS response contained invalid keys")
}
cached = CachedJwks(
keys = keys,
fetchedAtMillis = nowMillis,
expiresAtMillis = nowMillis + cacheTtl.toMillis(),
)
keys.getKeyByKeyId(keyId) as? RSAKey
}
private data class CachedJwks(
val keys: JWKSet,
val fetchedAtMillis: Long,
val expiresAtMillis: Long,
)
}
class AppleIdentityTokenVerifier(
private val config: AppleConfig,
private val jwksProvider: AppleJwksProvider,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun verify(identityToken: String, expectedNonce: String): AppleIdentity {
if (identityToken.isBlank() || identityToken.length > MAX_IDENTITY_TOKEN_LENGTH) {
throw AppleTokenInvalidException("Apple identity token has an invalid size")
}
if (expectedNonce.isBlank() || expectedNonce.length > MAX_NONCE_LENGTH) {
throw AppleTokenInvalidException("Expected nonce has an invalid size")
}
val jwt = runCatching { SignedJWT.parse(identityToken) }
.getOrElse { throw AppleTokenInvalidException("Malformed Apple identity token", it) }
if (jwt.header.algorithm != JWSAlgorithm.RS256) {
throw AppleTokenInvalidException("Apple identity token must use RS256")
}
val keyId = jwt.header.keyID?.takeIf(String::isNotBlank)
?: throw AppleTokenInvalidException("Apple identity token is missing kid")
val key = jwksProvider.rsaKey(keyId)
?: throw AppleTokenInvalidException("Apple identity token used an unknown key")
if (!key.isSuitableAppleSigningKey(keyId)) {
throw AppleTokenInvalidException("Apple identity token used an unsuitable key")
}
if (!runCatching { jwt.verify(RSASSAVerifier(key.toRSAPublicKey())) }.getOrDefault(false)) {
throw AppleTokenInvalidException("Apple identity token signature is invalid")
}
val claims = runCatching { jwt.jwtClaimsSet }
.getOrElse { throw AppleTokenInvalidException("Apple identity claims are invalid", it) }
val now = clock.instant()
if (claims.issuer != APPLE_ISSUER || claims.audience != listOf(config.clientId)) {
throw AppleTokenInvalidException("Apple identity token issuer or audience is invalid")
}
val expiresAt = claims.expirationTime?.toInstant()
if (expiresAt?.isAfter(now.minusSeconds(CLOCK_SKEW_SECONDS)) != true) {
throw AppleTokenInvalidException("Apple identity token has expired")
}
val issuedAt = claims.issueTime?.toInstant()
?: throw AppleTokenInvalidException("Apple identity token is missing iat")
if (issuedAt.isAfter(now.plusSeconds(CLOCK_SKEW_SECONDS)) || !expiresAt.isAfter(issuedAt)) {
throw AppleTokenInvalidException("Apple identity token time claims are invalid")
}
val actualNonce = runCatching { claims.getStringClaim(NONCE_CLAIM) }
.getOrElse { throw AppleTokenInvalidException("Apple identity token nonce is invalid", it) }
?: throw AppleTokenInvalidException("Apple identity token is missing nonce")
if (!AppleNonceVerifier.matches(expectedNonce, actualNonce)) {
throw AppleTokenInvalidException("Apple identity token nonce is invalid")
}
val subject = claims.subject?.takeIf { it.isNotBlank() && it.length <= MAX_SUBJECT_LENGTH }
?: throw AppleTokenInvalidException("Apple identity token is missing sub")
return AppleIdentity(subject)
}
}
internal fun RSAKey.isSuitableAppleSigningKey(expectedKeyId: String): Boolean = runCatching {
val operations = keyOperations
keyID == expectedKeyId &&
(algorithm == null || algorithm == JWSAlgorithm.RS256) &&
(keyUse == null || keyUse == KeyUse.SIGNATURE) &&
(operations.isNullOrEmpty() || KeyOperation.VERIFY in operations) &&
toRSAPublicKey().let { publicKey ->
publicKey.modulus.bitLength() >= MIN_RSA_KEY_BITS &&
publicKey.publicExponent >= MIN_RSA_PUBLIC_EXPONENT &&
publicKey.publicExponent.testBit(0)
}
}.getOrDefault(false)
/**
* Apple receives SHA-256(raw nonce) from the client. The server receives the
* original nonce and compares only its digest with the signed claim.
*/
internal object AppleNonceVerifier {
fun matches(rawNonce: String, signedClaim: String): Boolean {
if (rawNonce.isBlank() || !signedClaim.matches(SHA256_HEX)) return false
val expected = MessageDigest.getInstance("SHA-256")
.digest(rawNonce.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
return MessageDigest.isEqual(
expected.toByteArray(Charsets.US_ASCII),
signedClaim.lowercase().toByteArray(Charsets.US_ASCII),
)
}
}
class AppleTokenInvalidException(message: String, cause: Throwable? = null) :
SecurityException(message, cause)
class AppleVerificationUnavailableException(message: String, cause: Throwable? = null) :
IllegalStateException(message, cause)
private const val APPLE_ISSUER = "https://appleid.apple.com"
private const val NONCE_CLAIM = "nonce"
private const val MAX_SUBJECT_LENGTH = 128
private const val MIN_RSA_KEY_BITS = 2048
private const val MAX_IDENTITY_TOKEN_LENGTH = 16_384
private const val MAX_NONCE_LENGTH = 256
private const val MAX_JWK_COUNT = 20
private const val CLOCK_SKEW_SECONDS = 30L
private val SHA256_HEX = Regex("[A-Fa-f0-9]{64}")
private val KEY_MISS_REFRESH_INTERVAL: Duration = Duration.ofMinutes(1)
private val MAX_JWKS_CACHE_TTL: Duration = Duration.ofHours(24)
private val MIN_RSA_PUBLIC_EXPONENT: BigInteger = BigInteger.valueOf(65_537)
@@ -0,0 +1,243 @@
package com.osglab.account.features.auth
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.ECDSASigner
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import io.ktor.client.HttpClient
import io.ktor.client.plugins.timeout
import io.ktor.client.request.forms.submitForm
import io.ktor.client.statement.bodyAsText
import io.ktor.http.Parameters
import io.ktor.http.isSuccess
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.interfaces.ECPrivateKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.ECParameterSpec
import java.security.spec.PKCS8EncodedKeySpec
import java.time.Clock
import java.time.Duration
import java.util.Base64
import java.util.Date
data class AppleTokenExchange(
val refreshToken: String,
val identityToken: String,
) {
override fun toString(): String =
"AppleTokenExchange(refreshToken=[REDACTED], identityToken=[REDACTED])"
}
interface AppleTokenClient {
suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange
suspend fun revokeRefreshToken(refreshToken: String)
}
fun interface AppleClientSecretSigner {
fun create(): String
}
fun createAppleTokenClient(httpClient: HttpClient, config: AppleConfig): AppleTokenClient =
if (config.clientCredentialsAvailable) {
HttpAppleTokenClient(httpClient, config, AppleClientSecretProvider(config))
} else {
UnavailableAppleTokenClient()
}
class HttpAppleTokenClient(
private val httpClient: HttpClient,
private val config: AppleConfig,
private val clientSecretProvider: AppleClientSecretSigner,
private val json: Json = Json { ignoreUnknownKeys = true },
private val requestTimeoutMillis: Long = APPLE_REQUEST_TIMEOUT_MILLIS,
) : AppleTokenClient {
init {
require(requestTimeoutMillis > 0) { "Apple request timeout must be positive" }
}
override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange {
requireSecretSize(code, "authorization code", MAX_AUTHORIZATION_CODE_LENGTH)
val response = request(
url = config.tokenUrl,
parameters = Parameters.build {
append("client_id", config.clientId)
append("client_secret", clientSecretProvider.create())
append("code", code)
append("grant_type", "authorization_code")
},
)
val payload = runCatching { json.decodeFromString<AppleTokenResponse>(response.body) }
.getOrNull()
if (!response.success || payload?.error != null) {
throw AppleTokenEndpointException(
"Apple rejected the authorization code",
retryable = response.status.isRetryableAppleStatus(),
)
}
if (payload == null) {
throw AppleTokenEndpointException("Apple token response was invalid", true)
}
val identityToken = payload.identityToken
?.takeIf { it.isNotBlank() && it.length <= MAX_IDENTITY_TOKEN_LENGTH }
?: throw AppleTokenEndpointException("Apple token response omitted a valid id_token", false)
val refreshToken = payload.refreshToken
?.takeIf { it.isNotBlank() && it.length <= MAX_APPLE_REFRESH_TOKEN_LENGTH }
?: throw AppleTokenEndpointException("Apple token response omitted a valid refresh_token", false)
return AppleTokenExchange(refreshToken, identityToken)
}
override suspend fun revokeRefreshToken(refreshToken: String) {
requireSecretSize(refreshToken, "Apple refresh token", MAX_APPLE_REFRESH_TOKEN_LENGTH)
val response = request(
url = config.revokeUrl,
parameters = Parameters.build {
append("client_id", config.clientId)
append("client_secret", clientSecretProvider.create())
append("token", refreshToken)
append("token_type_hint", "refresh_token")
},
)
if (!response.success) {
throw AppleTokenEndpointException(
"Apple token revocation failed",
retryable = response.status.isRetryableAppleStatus(),
)
}
}
private fun requireSecretSize(value: String, label: String, maximumLength: Int) {
if (value.isBlank() || value.length > maximumLength) {
throw AppleTokenEndpointException("$label has an invalid size", false)
}
}
private suspend fun request(url: String, parameters: Parameters): AppleHttpResponse {
val response = runCatching {
httpClient.submitForm(url = url, formParameters = parameters) {
timeout {
connectTimeoutMillis = requestTimeoutMillis
requestTimeoutMillis = requestTimeoutMillis
socketTimeoutMillis = requestTimeoutMillis
}
}
}
.getOrElse { throw AppleTokenEndpointException("Apple token endpoint is unavailable", true, it) }
return AppleHttpResponse(
success = response.status.isSuccess(),
status = response.status.value,
body = response.bodyAsText(),
)
}
private data class AppleHttpResponse(
val success: Boolean,
val status: Int,
val body: String,
)
}
class AppleClientSecretProvider(
private val config: AppleConfig,
private val clock: Clock = Clock.systemUTC(),
) : AppleClientSecretSigner {
private val privateKey: ECPrivateKey by lazy(::loadPrivateKey)
override fun create(): String {
val teamId = config.teamId?.takeIf(String::isNotBlank)
?: throw AppleClientUnavailableException()
val keyId = config.keyId?.takeIf(String::isNotBlank)
?: throw AppleClientUnavailableException()
if (config.clientId.isBlank()) throw AppleClientUnavailableException()
if (config.privateKeyPem == null) throw AppleClientUnavailableException()
val now = clock.instant()
val claims = JWTClaimsSet.Builder()
.issuer(teamId)
.subject(config.clientId)
.audience(APPLE_ISSUER)
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plus(CLIENT_SECRET_LIFETIME)))
.build()
val jwt = SignedJWT(
JWSHeader.Builder(JWSAlgorithm.ES256).keyID(keyId).build(),
claims,
)
jwt.sign(ECDSASigner(privateKey))
return jwt.serialize()
}
private fun loadPrivateKey(): ECPrivateKey {
val pem = config.privateKeyPem?.trim() ?: throw AppleClientUnavailableException()
if (!pem.startsWith(PKCS8_PEM_BEGIN) || !pem.endsWith(PKCS8_PEM_END)) {
throw AppleClientUnavailableException("Apple private key must be PKCS#8 PEM")
}
val encoded = pem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(Regex("\\s"), "")
return runCatching {
val key = KeyFactory.getInstance("EC")
.generatePrivate(PKCS8EncodedKeySpec(Base64.getDecoder().decode(encoded))) as ECPrivateKey
requireP256(key)
key
}.getOrElse {
throw AppleClientUnavailableException("Apple private key is invalid", it)
}
}
private fun requireP256(key: ECPrivateKey) {
val expected = AlgorithmParameters.getInstance("EC").run {
init(ECGenParameterSpec("secp256r1"))
getParameterSpec(ECParameterSpec::class.java)
}
require(key.params.curve == expected.curve &&
key.params.generator == expected.generator &&
key.params.order == expected.order &&
key.params.cofactor == expected.cofactor
) {
"Apple private key must use P-256"
}
}
}
class UnavailableAppleTokenClient(
private val reason: String = "Apple client credentials are not configured",
) : AppleTokenClient {
override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange =
throw AppleClientUnavailableException(reason)
override suspend fun revokeRefreshToken(refreshToken: String): Unit =
throw AppleClientUnavailableException(reason)
}
class AppleClientUnavailableException(message: String = "Apple token client is unavailable", cause: Throwable? = null) :
IllegalStateException(message, cause)
class AppleTokenEndpointException(
message: String,
val retryable: Boolean,
cause: Throwable? = null,
) : IllegalStateException(message, cause)
@Serializable
private data class AppleTokenResponse(
@SerialName("refresh_token") val refreshToken: String? = null,
@SerialName("id_token") val identityToken: String? = null,
val error: String? = null,
)
private fun Int.isRetryableAppleStatus(): Boolean = this == 408 || this == 429 || this >= 500
private const val APPLE_ISSUER = "https://appleid.apple.com"
private const val APPLE_REQUEST_TIMEOUT_MILLIS = 10_000L
private const val MAX_AUTHORIZATION_CODE_LENGTH = 2_048
private const val MAX_APPLE_REFRESH_TOKEN_LENGTH = 4_096
private const val MAX_IDENTITY_TOKEN_LENGTH = 16_384
private const val PKCS8_PEM_BEGIN = "-----BEGIN PRIVATE KEY-----"
private const val PKCS8_PEM_END = "-----END PRIVATE KEY-----"
private val CLIENT_SECRET_LIFETIME: Duration = Duration.ofMinutes(5)
@@ -0,0 +1,346 @@
package com.osglab.account.features.auth
import com.osglab.account.config.DatabaseFactory
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.javatime.timestamp
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 java.time.Instant
import java.util.UUID
internal object AccountsTable : Table("accounts") {
val id = varchar("id", 36)
// The legacy column name is retained by V1, but its value is always AES-GCM ciphertext.
val encryptedAppleSubject = varchar("apple_sub", 255).uniqueIndex()
val identityFingerprint = char("identity_fingerprint", 64).nullable().uniqueIndex()
val antiAbuseRestricted = bool("anti_abuse_restricted")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
internal object AppleCredentialsTable : Table("apple_credentials") {
val accountId = varchar("account_id", 36)
val encryptedRefreshToken = text("encrypted_refresh_token")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(accountId)
}
internal object AccountIdentityTombstonesTable : Table("account_identity_tombstones") {
val identityFingerprint = char("identity_fingerprint", 64)
val deletedAt = timestamp("deleted_at")
val expiresAt = timestamp("expires_at")
override val primaryKey = PrimaryKey(identityFingerprint)
}
internal object SessionsTable : Table("sessions") {
val id = varchar("id", 36)
val accountId = varchar("account_id", 36).index()
val familyId = varchar("family_id", 36).index()
val refreshTokenHash = varchar("refresh_token_hash", 64).uniqueIndex()
val replacedById = varchar("replaced_by_id", 36).nullable()
val createdAt = timestamp("created_at")
val expiresAt = timestamp("expires_at")
val revokedAt = timestamp("revoked_at").nullable()
val reuseDetectedAt = timestamp("reuse_detected_at").nullable()
override val primaryKey = PrimaryKey(id)
}
data class AuthAccount(
val id: UUID,
val identityFingerprint: String,
val antiAbuseRestricted: Boolean,
)
data class CreatedSession(
val accountId: UUID,
val sessionId: UUID,
val familyId: UUID,
)
sealed interface RefreshRotationResult {
data class Rotated(
val accountId: UUID,
val sessionId: UUID,
val familyId: UUID,
) : RefreshRotationResult
data object Invalid : RefreshRotationResult
data object ReuseDetected : RefreshRotationResult
}
internal enum class RefreshRotationDecision {
ROTATE,
REVOKE_EXPIRED,
REVOKE_REUSED_FAMILY,
}
/**
* Keeps the security-sensitive refresh state transition independent from SQL,
* so every repository implementation applies the same replay policy.
*/
internal object RefreshRotationPolicy {
fun decide(
revoked: Boolean,
replaced: Boolean,
expiresAt: Instant,
now: Instant,
): RefreshRotationDecision = when {
revoked || replaced -> RefreshRotationDecision.REVOKE_REUSED_FAMILY
!expiresAt.isAfter(now) -> RefreshRotationDecision.REVOKE_EXPIRED
else -> RefreshRotationDecision.ROTATE
}
}
interface AuthRepository {
suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount
suspend fun updateAppleRefreshToken(accountId: UUID, encryptedToken: String, now: Instant)
suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession
suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult
suspend fun revokeSessionFamily(accountId: UUID, sessionId: UUID, now: Instant): Boolean
suspend fun isSessionActive(accountId: UUID, sessionId: UUID, now: Instant): Boolean
suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant)
}
class ExposedAuthRepository(
private val databaseFactory: DatabaseFactory,
) : AuthRepository {
override suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount =
databaseFactory.withAppleIdentityLock(identityFingerprint) {
databaseFactory.query {
val restricted = AccountIdentityTombstonesTable.selectAll()
.where {
(AccountIdentityTombstonesTable.identityFingerprint eq identityFingerprint) and
(AccountIdentityTombstonesTable.expiresAt greater now)
}
.singleOrNull() != null
val id = UUID.randomUUID()
AccountsTable.insertIgnore {
it[AccountsTable.id] = id.toString()
it[AccountsTable.encryptedAppleSubject] = encryptedAppleSubject
it[AccountsTable.identityFingerprint] = identityFingerprint
it[AccountsTable.antiAbuseRestricted] = restricted
it[AccountsTable.createdAt] = now
it[AccountsTable.updatedAt] = now
}
AccountsTable.selectAll()
.where { AccountsTable.identityFingerprint eq identityFingerprint }
.single()
.toAuthAccount()
}
}
override suspend fun updateAppleRefreshToken(
accountId: UUID,
encryptedToken: String,
now: Instant,
) {
val fingerprint = databaseFactory.query {
AccountsTable.selectAll()
.where { AccountsTable.id eq accountId.toString() }
.singleOrNull()
?.get(AccountsTable.identityFingerprint)
} ?: return
databaseFactory.withAppleIdentityLock(fingerprint) {
databaseFactory.query {
val accountStillExists = AccountsTable.selectAll()
.where {
(AccountsTable.id eq accountId.toString()) and
(AccountsTable.identityFingerprint eq fingerprint)
}
.limit(1)
.singleOrNull() != null
check(accountStillExists) { "Account was deleted during Apple sign-in" }
AppleCredentialsTable.insertIgnore {
it[AppleCredentialsTable.accountId] = accountId.toString()
it[AppleCredentialsTable.encryptedRefreshToken] = encryptedToken
it[AppleCredentialsTable.createdAt] = now
it[AppleCredentialsTable.updatedAt] = now
}
AppleCredentialsTable.update({
AppleCredentialsTable.accountId eq accountId.toString()
}) {
it[encryptedRefreshToken] = encryptedToken
it[updatedAt] = now
}
}
}
}
override suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession = databaseFactory.query {
val sessionId = UUID.randomUUID()
val familyId = sessionId
SessionsTable.insert {
it[SessionsTable.id] = sessionId.toString()
it[SessionsTable.accountId] = accountId.toString()
it[SessionsTable.familyId] = familyId.toString()
it[SessionsTable.refreshTokenHash] = refreshTokenHash
it[SessionsTable.createdAt] = now
it[SessionsTable.expiresAt] = expiresAt
}
CreatedSession(accountId, sessionId, familyId)
}
override suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult = databaseFactory.query {
val current = SessionsTable.selectAll()
.where { SessionsTable.refreshTokenHash eq currentTokenHash }
.forUpdate()
.singleOrNull()
?: return@query RefreshRotationResult.Invalid
val familyId = current[SessionsTable.familyId]
when (
RefreshRotationPolicy.decide(
revoked = current[SessionsTable.revokedAt] != null,
replaced = current[SessionsTable.replacedById] != null,
expiresAt = current[SessionsTable.expiresAt],
now = now,
)
) {
RefreshRotationDecision.REVOKE_REUSED_FAMILY -> {
SessionsTable.update({ SessionsTable.familyId eq familyId }) {
it[SessionsTable.revokedAt] = now
}
SessionsTable.update({ SessionsTable.id eq current[SessionsTable.id] }) {
it[SessionsTable.reuseDetectedAt] = now
}
return@query RefreshRotationResult.ReuseDetected
}
RefreshRotationDecision.REVOKE_EXPIRED -> {
SessionsTable.update({ SessionsTable.familyId eq familyId }) {
it[SessionsTable.revokedAt] = now
}
return@query RefreshRotationResult.Invalid
}
RefreshRotationDecision.ROTATE -> Unit
}
val newSessionId = UUID.randomUUID()
SessionsTable.insert {
it[SessionsTable.id] = newSessionId.toString()
it[SessionsTable.accountId] = current[SessionsTable.accountId]
it[SessionsTable.familyId] = familyId
it[SessionsTable.refreshTokenHash] = newTokenHash
it[SessionsTable.createdAt] = now
it[SessionsTable.expiresAt] = newExpiresAt
}
SessionsTable.update({ SessionsTable.id eq current[SessionsTable.id] }) {
it[SessionsTable.replacedById] = newSessionId.toString()
it[SessionsTable.revokedAt] = now
}
RefreshRotationResult.Rotated(
accountId = UUID.fromString(current[SessionsTable.accountId]),
sessionId = newSessionId,
familyId = UUID.fromString(familyId),
)
}
override suspend fun revokeSessionFamily(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = databaseFactory.query {
val session = SessionsTable.selectAll()
.where {
(SessionsTable.id eq sessionId.toString()) and
(SessionsTable.accountId eq accountId.toString())
}
.forUpdate()
.singleOrNull()
?: return@query false
SessionsTable.update({
(SessionsTable.accountId eq accountId.toString()) and
(SessionsTable.familyId eq session[SessionsTable.familyId])
}) {
it[SessionsTable.revokedAt] = now
} > 0
}
override suspend fun isSessionActive(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = databaseFactory.query {
val accountExists = AccountsTable.selectAll()
.where { AccountsTable.id eq accountId.toString() }
.limit(1)
.singleOrNull() != null
accountExists && SessionsTable.selectAll()
.where {
(SessionsTable.id eq sessionId.toString()) and
(SessionsTable.accountId eq accountId.toString()) and
SessionsTable.revokedAt.isNull() and
(SessionsTable.expiresAt greater now)
}
.limit(1)
.singleOrNull() != null
}
override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) {
databaseFactory.query {
AccountsTable.update({ AccountsTable.id eq accountId.toString() }) {
it[antiAbuseRestricted] = true
it[updatedAt] = now
}
}
}
}
private fun ResultRow.toAuthAccount(): AuthAccount = AuthAccount(
id = UUID.fromString(this[AccountsTable.id]),
identityFingerprint = requireNotNull(this[AccountsTable.identityFingerprint]),
antiAbuseRestricted = this[AccountsTable.antiAbuseRestricted],
)
internal suspend fun <T> DatabaseFactory.withAppleIdentityLock(
identityFingerprint: String,
block: suspend () -> T,
): T {
require(identityFingerprint.length == IDENTITY_FINGERPRINT_LENGTH)
return withMysqlNamedLock(
"apple-id:${identityFingerprint.take(IDENTITY_LOCK_FINGERPRINT_LENGTH)}",
IDENTITY_LOCK_TIMEOUT_SECONDS,
block,
)
}
private const val IDENTITY_FINGERPRINT_LENGTH = 64
private const val IDENTITY_LOCK_FINGERPRINT_LENGTH = 55
private const val IDENTITY_LOCK_TIMEOUT_SECONDS = 10
@@ -0,0 +1,118 @@
package com.osglab.account.features.auth
import com.osglab.account.common.api.ApiResponse
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.SESSION_AUTH_NAME
import com.osglab.account.features.integrity.AppAttestEvidence
import com.osglab.account.features.integrity.IntegrityEvidence
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
@Serializable
data class AppleSignInRequest(
val identityToken: String,
val authorizationCode: String,
val nonce: String,
val deviceCheckToken: String? = null,
val appAttest: AppAttestRequest? = null,
) {
override fun toString(): String =
"AppleSignInRequest(identityToken=[REDACTED], authorizationCode=[REDACTED], " +
"nonce=[REDACTED], deviceCheckToken=[REDACTED], appAttest=[REDACTED])"
}
@Serializable
data class AppAttestRequest(
val keyId: String,
val challengeId: String,
val challenge: String,
val assertion: String,
) {
override fun toString(): String =
"AppAttestRequest(keyId=[REDACTED], challengeId=[REDACTED], " +
"challenge=[REDACTED], assertion=[REDACTED])"
}
@Serializable
data class RefreshSessionRequest(val refreshToken: String) {
override fun toString(): String = "RefreshSessionRequest(refreshToken=[REDACTED])"
}
@Serializable
data class SessionTokenResponse(
val accountId: String,
val tokenType: String = "Bearer",
val accessToken: String,
val accessTokenExpiresAtEpochSeconds: Long,
val refreshToken: String,
val refreshTokenExpiresAtEpochSeconds: Long,
) {
override fun toString(): String =
"SessionTokenResponse(accountId=$accountId, tokenType=$tokenType, " +
"accessToken=[REDACTED], accessTokenExpiresAtEpochSeconds=$accessTokenExpiresAtEpochSeconds, " +
"refreshToken=[REDACTED], refreshTokenExpiresAtEpochSeconds=$refreshTokenExpiresAtEpochSeconds)"
}
class AuthRoutes(
private val sessionService: SessionService,
) {
fun register(parent: Route) {
with(parent) {
route("/v1/auth") {
post("/apple") {
val request = call.receive<AppleSignInRequest>()
val tokens = sessionService.signInWithApple(
identityToken = request.identityToken,
authorizationCode = request.authorizationCode,
nonce = request.nonce,
integrityEvidence = IntegrityEvidence(
deviceCheckToken = request.deviceCheckToken,
appAttest = request.appAttest?.let {
AppAttestEvidence(
keyId = it.keyId,
challengeId = it.challengeId,
assertion = it.assertion,
challenge = it.challenge,
)
},
),
)
call.respond(ApiResponse(data = tokens.toResponse()))
}
post("/refresh") {
val request = call.receive<RefreshSessionRequest>()
call.respond(
ApiResponse(data = sessionService.refresh(request.refreshToken).toResponse()),
)
}
authenticate(SESSION_AUTH_NAME) {
post("/logout") {
val principal = call.principal<AccountPrincipal>()
?: throw UnauthorizedException()
sessionService.logout(principal)
call.respond(HttpStatusCode.NoContent)
}
}
}
}
}
}
fun Route.authRoutes(sessionService: SessionService) =
AuthRoutes(sessionService).register(this)
private fun SessionTokens.toResponse(): SessionTokenResponse = SessionTokenResponse(
accountId = accountId.toString(),
accessToken = accessToken,
accessTokenExpiresAtEpochSeconds = accessTokenExpiresAt.epochSecond,
refreshToken = refreshToken,
refreshTokenExpiresAtEpochSeconds = refreshTokenExpiresAt.epochSecond,
)
@@ -0,0 +1,23 @@
package com.osglab.account.features.auth
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.SessionJwt
import java.time.Clock
/**
* Access tokens are accepted only while their account and refresh-token family
* still exist and remain active. This makes logout, replay response and account
* deletion immediately effective for every authenticated request.
*/
class SessionAccessAuthenticator(
private val sessionJwt: SessionJwt,
private val repository: AuthRepository,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun authenticate(serialized: String): AccountPrincipal? {
val principal = sessionJwt.verify(serialized) ?: return null
return principal.takeIf {
repository.isSessionActive(it.userId, it.sessionId, clock.instant())
}
}
}
@@ -0,0 +1,185 @@
package com.osglab.account.features.auth
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.common.errors.TokenReuseException
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.common.security.SecureTokenGenerator
import com.osglab.account.common.security.Sha256SecureTokenGenerator
import com.osglab.account.common.security.SessionJwt
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.TokenHash
import com.osglab.account.config.SessionConfig
import com.osglab.account.features.integrity.AppleSignInIntegrityPayload
import com.osglab.account.features.integrity.IntegrityEvidence
import com.osglab.account.features.integrity.IntegrityService
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.UUID
data class SessionTokens(
val accountId: UUID,
val accessToken: String,
val accessTokenExpiresAt: Instant,
val refreshToken: String,
val refreshTokenExpiresAt: Instant,
) {
override fun toString(): String =
"SessionTokens(accountId=$accountId, accessToken=[REDACTED], " +
"accessTokenExpiresAt=$accessTokenExpiresAt, refreshToken=[REDACTED], " +
"refreshTokenExpiresAt=$refreshTokenExpiresAt)"
}
fun interface AccountProvisioner {
suspend fun provision(accountId: UUID, deviceCheckToken: String?)
}
class SessionService(
private val repository: AuthRepository,
private val appleIdentityVerifier: AppleIdentityTokenVerifier,
private val appleTokenClient: AppleTokenClient,
private val integrityService: IntegrityService,
private val sessionJwt: SessionJwt,
private val fieldEncryptor: FieldEncryptor,
private val identityFingerprint: IdentityFingerprint,
private val sessionConfig: SessionConfig,
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _ -> },
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun signInWithApple(
identityToken: String,
authorizationCode: String,
nonce: String,
integrityEvidence: IntegrityEvidence,
): SessionTokens {
requireValue(identityToken, "identityToken", MAX_IDENTITY_TOKEN_LENGTH)
requireValue(authorizationCode, "authorizationCode", MAX_AUTHORIZATION_CODE_LENGTH)
requireValue(nonce, "nonce", MAX_NONCE_LENGTH)
val verifiedIntegrity = integrityService.verifyAppleSignIn(
integrityEvidence,
AppleSignInIntegrityPayload(identityToken, authorizationCode, nonce),
)
val suppliedIdentity = verifyIdentityToken(identityToken, nonce)
val exchange = exchangeCode(authorizationCode)
val exchangedIdentity = verifyIdentityToken(exchange.identityToken, nonce)
if (suppliedIdentity.subject != exchangedIdentity.subject) {
throw UnauthorizedException("Apple authorization code does not match identity token")
}
val now = clock.instant()
val fingerprint = identityFingerprint.ofAppleSubject(suppliedIdentity.subject)
val account = repository.findOrCreateAccount(
identityFingerprint = fingerprint,
encryptedAppleSubject = fieldEncryptor.encrypt(
suppliedIdentity.subject,
appleSubjectContext(fingerprint),
),
now = now,
)
integrityService.bindVerifiedKey(verifiedIntegrity.appAttestKeyId, account.id)
repository.updateAppleRefreshToken(
account.id,
fieldEncryptor.encrypt(exchange.refreshToken, appleRefreshContext(account.id)),
now,
)
accountProvisioner.provision(
account.id,
verifiedIntegrity.deviceCheckTokenForTrial.takeUnless { account.antiAbuseRestricted },
)
return createSession(account.id, now)
}
suspend fun refresh(refreshToken: String): SessionTokens {
requireValue(refreshToken, "refreshToken", MAX_REFRESH_TOKEN_LENGTH)
val now = clock.instant()
val replacement = tokenGenerator.newRefreshToken()
val replacementExpiresAt = now.plus(Duration.ofDays(sessionConfig.refreshDays))
return when (
val result = repository.rotateRefreshToken(
currentTokenHash = TokenHash.sha256(refreshToken),
newTokenHash = TokenHash.sha256(replacement),
newExpiresAt = replacementExpiresAt,
now = now,
)
) {
RefreshRotationResult.Invalid -> throw UnauthorizedException("Refresh token is invalid or expired")
RefreshRotationResult.ReuseDetected -> throw TokenReuseException()
is RefreshRotationResult.Rotated -> {
val access = sessionJwt.issue(result.accountId, result.sessionId)
SessionTokens(
accountId = result.accountId,
accessToken = access.value,
accessTokenExpiresAt = access.expiresAt,
refreshToken = replacement,
refreshTokenExpiresAt = replacementExpiresAt,
)
}
}
}
suspend fun logout(principal: AccountPrincipal) {
repository.revokeSessionFamily(principal.userId, principal.sessionId, clock.instant())
}
private suspend fun createSession(accountId: UUID, now: Instant): SessionTokens {
val refreshToken = tokenGenerator.newRefreshToken()
val refreshExpiresAt = now.plus(Duration.ofDays(sessionConfig.refreshDays))
val created = repository.createSession(
accountId = accountId,
refreshTokenHash = TokenHash.sha256(refreshToken),
expiresAt = refreshExpiresAt,
now = now,
)
val access = sessionJwt.issue(accountId, created.sessionId)
return SessionTokens(
accountId = accountId,
accessToken = access.value,
accessTokenExpiresAt = access.expiresAt,
refreshToken = refreshToken,
refreshTokenExpiresAt = refreshExpiresAt,
)
}
private suspend fun verifyIdentityToken(token: String, nonce: String): AppleIdentity =
try {
appleIdentityVerifier.verify(token, nonce)
} catch (exception: AppleVerificationUnavailableException) {
throw ExternalServiceUnavailableException("Apple identity verification")
} catch (exception: AppleTokenInvalidException) {
throw UnauthorizedException("Apple identity token is invalid")
}
private suspend fun exchangeCode(code: String): AppleTokenExchange =
try {
appleTokenClient.exchangeAuthorizationCode(code)
} catch (exception: AppleClientUnavailableException) {
throw ExternalServiceUnavailableException("Apple token service")
} catch (exception: AppleTokenEndpointException) {
if (exception.retryable) {
throw ExternalServiceUnavailableException("Apple token service")
}
throw UnauthorizedException("Apple authorization code is invalid")
}
private fun requireValue(value: String, name: String, maxLength: Int) {
if (value.isBlank()) throw InvalidRequestException("$name must not be blank")
if (value.length > maxLength) {
throw InvalidRequestException("$name exceeds the maximum length")
}
}
private companion object {
const val MAX_IDENTITY_TOKEN_LENGTH = 16_384
const val MAX_AUTHORIZATION_CODE_LENGTH = 2_048
const val MAX_NONCE_LENGTH = 256
const val MAX_REFRESH_TOKEN_LENGTH = 512
}
}
fun appleRefreshContext(accountId: UUID): String = "apple-refresh-token:$accountId"
fun appleSubjectContext(identityFingerprint: String): String = "apple-subject:$identityFingerprint"
@@ -0,0 +1,239 @@
package com.osglab.account.features.credits.domain
import java.math.BigInteger
import java.security.MessageDigest
import java.time.Instant
import java.util.UUID
import kotlinx.serialization.Serializable
@Serializable
enum class UsageKind {
ASR,
LLM,
}
enum class ReservationStatus {
RESERVED,
SETTLED,
RELEASED,
REFUNDED,
}
object ReservationStateRules {
fun canTransition(from: ReservationStatus, to: ReservationStatus): Boolean =
when (from) {
ReservationStatus.RESERVED ->
to == ReservationStatus.SETTLED || to == ReservationStatus.RELEASED
ReservationStatus.SETTLED -> to == ReservationStatus.REFUNDED
ReservationStatus.RELEASED,
ReservationStatus.REFUNDED,
-> false
}
}
enum class LedgerEntryType {
SIGNUP_TRIAL,
MANUAL_GRANT,
USAGE_RESERVE,
USAGE_SETTLE,
USAGE_RELEASE,
USAGE_REFUND,
REFERRAL_INVITER,
REFERRAL_INVITEE,
STOREKIT_PURCHASE,
SUBSCRIPTION_GRANT,
}
data class CreditAccount(
val userId: UUID,
val balance: Long,
val updatedAt: Instant,
)
data class LedgerEntry(
val id: UUID,
val userId: UUID,
val type: LedgerEntryType,
val amountDelta: Long,
val balanceAfter: Long,
val idempotencyKey: String,
val referenceId: UUID?,
val createdAt: Instant,
)
/**
* Billing metadata only. Provider input, audio, prompts and responses must
* never be persisted in a usage record.
*/
data class CreditUsageRecord(
val id: UUID,
val reservationId: UUID,
val userId: UUID,
val rateVersionId: UUID,
val usage: UsageMeasurement,
val chargedCredits: Long,
val createdAt: Instant,
)
sealed interface UsageMeasurement {
val kind: UsageKind
data class Asr(
val durationMillis: Long,
) : UsageMeasurement {
override val kind: UsageKind = UsageKind.ASR
init {
require(durationMillis >= 0) { "ASR duration must not be negative" }
}
}
data class Llm(
val inputTokens: Long,
val outputTokens: Long,
) : UsageMeasurement {
override val kind: UsageKind = UsageKind.LLM
init {
require(inputTokens >= 0) { "LLM input tokens must not be negative" }
require(outputTokens >= 0) { "LLM output tokens must not be negative" }
}
}
}
data class CreditRateVersion(
val id: UUID,
val kind: UsageKind,
val provider: String,
val model: String,
val effectiveFrom: Instant,
val effectiveUntil: Instant?,
val asrCreditsNumerator: Long?,
val asrMillisDenominator: Long?,
val inputCreditsNumerator: Long?,
val inputTokensDenominator: Long?,
val outputCreditsNumerator: Long?,
val outputTokensDenominator: Long?,
) {
init {
require(provider.isNotBlank()) { "Provider must not be blank" }
require(model.isNotBlank()) { "Model must not be blank" }
require(effectiveUntil == null || effectiveUntil > effectiveFrom) {
"Rate validity interval is invalid"
}
when (kind) {
UsageKind.ASR -> {
requirePositive(asrCreditsNumerator, "ASR numerator")
requirePositive(asrMillisDenominator, "ASR denominator")
require(inputCreditsNumerator == null && inputTokensDenominator == null)
require(outputCreditsNumerator == null && outputTokensDenominator == null)
}
UsageKind.LLM -> {
requirePositive(inputCreditsNumerator, "Input numerator")
requirePositive(inputTokensDenominator, "Input denominator")
requirePositive(outputCreditsNumerator, "Output numerator")
requirePositive(outputTokensDenominator, "Output denominator")
require(asrCreditsNumerator == null && asrMillisDenominator == null)
}
}
}
private fun requirePositive(value: Long?, name: String) {
require(value != null && value > 0) { "$name must be positive" }
}
}
data class CreditReservation(
val id: UUID,
val userId: UUID,
val rateVersionId: UUID,
val provider: String,
val model: String,
val estimatedUsage: UsageMeasurement,
val actualUsage: UsageMeasurement?,
val reservedCredits: Long,
val settledCredits: Long?,
val status: ReservationStatus,
val managedCall: Boolean,
val reserveIdempotencyKey: String,
val settleIdempotencyKey: String?,
val releaseIdempotencyKey: String?,
val refundIdempotencyKey: String?,
val createdAt: Instant,
val updatedAt: Instant,
)
object CreditCostCalculator {
fun calculate(rate: CreditRateVersion, usage: UsageMeasurement): Long {
require(rate.kind == usage.kind) { "Usage kind does not match rate version" }
return try {
when (usage) {
is UsageMeasurement.Asr -> ceilMultiplyDivide(
usage.durationMillis,
requireNotNull(rate.asrCreditsNumerator),
requireNotNull(rate.asrMillisDenominator),
)
is UsageMeasurement.Llm -> Math.addExact(
ceilMultiplyDivide(
usage.inputTokens,
requireNotNull(rate.inputCreditsNumerator),
requireNotNull(rate.inputTokensDenominator),
),
ceilMultiplyDivide(
usage.outputTokens,
requireNotNull(rate.outputCreditsNumerator),
requireNotNull(rate.outputTokensDenominator),
),
)
}
} catch (_: ArithmeticException) {
throw InvalidCreditRequest("Calculated credit cost exceeds the supported integer range")
}
}
private fun ceilMultiplyDivide(units: Long, numerator: Long, denominator: Long): Long {
if (units == 0L) return 0L
val product = BigInteger.valueOf(units).multiply(BigInteger.valueOf(numerator))
val divisor = BigInteger.valueOf(denominator)
val (quotient, remainder) = product.divideAndRemainder(divisor)
return quotient
.add(if (remainder.signum() == 0) BigInteger.ZERO else BigInteger.ONE)
.longValueExact()
}
}
open class CreditException(message: String) : RuntimeException(message)
class InvalidCreditRequest(message: String) : CreditException(message)
class InsufficientCredits(
val available: Long,
val required: Long,
) : CreditException("Insufficient credits: available=$available, required=$required")
class CreditConflict(message: String) : CreditException(message)
class CreditNotFound(message: String) : CreditException(message)
internal fun validatedIdempotencyKey(value: String): String {
val normalized = value.trim()
if (normalized.length !in 8..128) {
throw InvalidCreditRequest("Idempotency key must contain 8 to 128 characters")
}
return normalized
}
/**
* Public callers are confined to a one-way namespace so they can never reserve
* service-owned ledger keys such as referral, trial or gateway operations.
*/
fun externalIdempotencyKey(value: String): String {
val normalized = validatedIdempotencyKey(value)
val digest = MessageDigest.getInstance("SHA-256")
.digest(normalized.toByteArray(Charsets.UTF_8))
.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
return "external:$digest"
}
@@ -0,0 +1,170 @@
package com.osglab.account.features.credits.models
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.credits.domain.UsageMeasurement
import kotlinx.serialization.Serializable
@Serializable
data class UsageDto(
val kind: UsageKind,
val asrMillis: Long? = null,
val inputTokens: Long? = null,
val outputTokens: Long? = null,
) {
fun toDomain(): UsageMeasurement = when (kind) {
UsageKind.ASR -> {
if (inputTokens != null || outputTokens != null || asrMillis == null || asrMillis < 0) {
throw InvalidCreditRequest("ASR usage requires only a non-negative asrMillis value")
}
UsageMeasurement.Asr(asrMillis)
}
UsageKind.LLM -> {
if (asrMillis != null || inputTokens == null || outputTokens == null ||
inputTokens < 0 || outputTokens < 0
) {
throw InvalidCreditRequest(
"LLM usage requires non-negative inputTokens and outputTokens values",
)
}
UsageMeasurement.Llm(inputTokens, outputTokens)
}
}
companion object {
fun fromDomain(usage: UsageMeasurement): UsageDto = when (usage) {
is UsageMeasurement.Asr -> UsageDto(UsageKind.ASR, asrMillis = usage.durationMillis)
is UsageMeasurement.Llm -> UsageDto(
kind = UsageKind.LLM,
inputTokens = usage.inputTokens,
outputTokens = usage.outputTokens,
)
}
}
}
@Serializable
data class ReserveCreditsRequest(
val provider: String,
val model: String,
val estimatedUsage: UsageDto,
)
@Serializable
data class SettleCreditsRequest(
val actualUsage: UsageDto,
)
@Serializable
data class CreditAccountDto(
val userId: String,
val balance: Long,
val updatedAt: String,
) {
companion object {
fun fromDomain(account: CreditAccount) = CreditAccountDto(
userId = account.userId.toString(),
balance = account.balance,
updatedAt = account.updatedAt.toString(),
)
}
}
@Serializable
data class LedgerEntryDto(
val id: String,
val type: String,
val amountDelta: Long,
val balanceAfter: Long,
val referenceId: String?,
val createdAt: String,
) {
companion object {
fun fromDomain(entry: LedgerEntry) = LedgerEntryDto(
id = entry.id.toString(),
type = entry.type.name,
amountDelta = entry.amountDelta,
balanceAfter = entry.balanceAfter,
referenceId = entry.referenceId?.toString(),
createdAt = entry.createdAt.toString(),
)
}
}
@Serializable
data class CreditReservationDto(
val id: String,
val userId: String,
val rateVersionId: String,
val provider: String,
val model: String,
val estimatedUsage: UsageDto,
val actualUsage: UsageDto?,
val reservedCredits: Long,
val settledCredits: Long?,
val status: String,
val managedCall: Boolean,
val createdAt: String,
val updatedAt: String,
) {
companion object {
fun fromDomain(reservation: CreditReservation) = CreditReservationDto(
id = reservation.id.toString(),
userId = reservation.userId.toString(),
rateVersionId = reservation.rateVersionId.toString(),
provider = reservation.provider,
model = reservation.model,
estimatedUsage = UsageDto.fromDomain(reservation.estimatedUsage),
actualUsage = reservation.actualUsage?.let(UsageDto::fromDomain),
reservedCredits = reservation.reservedCredits,
settledCredits = reservation.settledCredits,
status = reservation.status.name,
managedCall = reservation.managedCall,
createdAt = reservation.createdAt.toString(),
updatedAt = reservation.updatedAt.toString(),
)
}
}
@Serializable
data class CreditRateVersionDto(
val id: String,
val kind: UsageKind,
val provider: String,
val model: String,
val effectiveFrom: String,
val effectiveUntil: String?,
val asrCreditsNumerator: Long?,
val asrMillisDenominator: Long?,
val inputCreditsNumerator: Long?,
val inputTokensDenominator: Long?,
val outputCreditsNumerator: Long?,
val outputTokensDenominator: Long?,
) {
companion object {
fun fromDomain(rate: CreditRateVersion) = CreditRateVersionDto(
id = rate.id.toString(),
kind = rate.kind,
provider = rate.provider,
model = rate.model,
effectiveFrom = rate.effectiveFrom.toString(),
effectiveUntil = rate.effectiveUntil?.toString(),
asrCreditsNumerator = rate.asrCreditsNumerator,
asrMillisDenominator = rate.asrMillisDenominator,
inputCreditsNumerator = rate.inputCreditsNumerator,
inputTokensDenominator = rate.inputTokensDenominator,
outputCreditsNumerator = rate.outputCreditsNumerator,
outputTokensDenominator = rate.outputTokensDenominator,
)
}
}
@Serializable
data class CreditErrorDto(
val error: String,
)
@@ -0,0 +1,55 @@
package com.osglab.account.features.credits.repositories
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
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 java.time.Instant
import java.util.UUID
interface CreditsRepository {
fun createAccountIfAbsent(userId: UUID, now: Instant)
fun lockAccount(userId: UUID): CreditAccount
fun updateAccountBalance(userId: UUID, newBalance: Long, now: Instant): CreditAccount
fun findLedgerEntry(userId: UUID, idempotencyKey: String): LedgerEntry?
fun insertLedgerEntry(entry: LedgerEntry)
fun listLedgerEntries(userId: UUID, limit: Int): List<LedgerEntry>
fun insertUsageRecord(record: CreditUsageRecord)
fun findReservationByReserveKey(userId: UUID, idempotencyKey: String): CreditReservation?
fun lockReservation(id: UUID): CreditReservation?
fun insertReservation(reservation: CreditReservation)
fun updateReservation(reservation: CreditReservation)
fun findRateVersion(id: UUID): CreditRateVersion?
fun findEffectiveRate(
kind: UsageKind,
provider: String,
model: String,
at: Instant,
): CreditRateVersion?
fun listEffectiveRates(at: Instant): List<CreditRateVersion>
}
interface BillingUnitOfWork {
val credits: CreditsRepository
val referrals: ReferralsRepository
}
interface BillingTransactionRunner {
suspend fun <T> inTransaction(block: (BillingUnitOfWork) -> T): T
}
@@ -0,0 +1,674 @@
package com.osglab.account.features.credits.repositories
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.domain.ReservationStatus
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.credits.domain.UsageMeasurement
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID
import com.osglab.account.features.referrals.domain.ReferralCampaign
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.javatime.timestamp
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.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
import java.util.UUID
private object CreditAccounts : Table("credit_accounts") {
val userId = varchar("user_id", 36)
val balance = long("balance")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(userId)
}
private object CreditLedger : Table("credit_ledger") {
val id = varchar("id", 36)
val userId = varchar("user_id", 36)
val entryType = enumerationByName<LedgerEntryType>("entry_type", 32)
val amountDelta = long("amount_delta")
val balanceAfter = long("balance_after")
val idempotencyKey = varchar("idempotency_key", 128)
val referenceId = varchar("reference_id", 36).nullable()
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object CreditUsageRecords : Table("credit_usage_records") {
val id = varchar("id", 36)
val reservationId = varchar("reservation_id", 36)
val userId = varchar("user_id", 36)
val rateVersionId = varchar("rate_version_id", 36)
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
val asrMillis = long("asr_millis").nullable()
val inputTokens = long("input_tokens").nullable()
val outputTokens = long("output_tokens").nullable()
val chargedCredits = long("charged_credits")
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object CreditRateVersions : Table("credit_rate_versions") {
val id = varchar("id", 36)
val kind = enumerationByName<UsageKind>("kind", 8)
val provider = varchar("provider", 100)
val model = varchar("model", 100)
val effectiveFrom = timestamp("effective_from")
val effectiveUntil = timestamp("effective_until").nullable()
val asrCreditsNumerator = long("asr_credits_numerator").nullable()
val asrMillisDenominator = long("asr_millis_denominator").nullable()
val inputCreditsNumerator = long("input_credits_numerator").nullable()
val inputTokensDenominator = long("input_tokens_denominator").nullable()
val outputCreditsNumerator = long("output_credits_numerator").nullable()
val outputTokensDenominator = long("output_tokens_denominator").nullable()
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object CreditReservations : Table("credit_reservations") {
val id = varchar("id", 36)
val userId = varchar("user_id", 36)
val rateVersionId = varchar("rate_version_id", 36)
val provider = varchar("provider", 100)
val model = varchar("model", 100)
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
val estimatedAsrMillis = long("estimated_asr_millis").nullable()
val estimatedInputTokens = long("estimated_input_tokens").nullable()
val estimatedOutputTokens = long("estimated_output_tokens").nullable()
val actualAsrMillis = long("actual_asr_millis").nullable()
val actualInputTokens = long("actual_input_tokens").nullable()
val actualOutputTokens = long("actual_output_tokens").nullable()
val reservedCredits = long("reserved_credits")
val settledCredits = long("settled_credits").nullable()
val status = enumerationByName<ReservationStatus>("status", 16)
val managedCall = bool("managed_call")
val reserveIdempotencyKey = varchar("reserve_idempotency_key", 128)
val settleIdempotencyKey = varchar("settle_idempotency_key", 128).nullable()
val releaseIdempotencyKey = varchar("release_idempotency_key", 128).nullable()
val refundIdempotencyKey = varchar("refund_idempotency_key", 128).nullable()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
private object ReferralCampaigns : Table("referral_campaigns") {
val id = varchar("id", 36)
val name = varchar("name", 100)
val startsAt = timestamp("starts_at")
val endsAt = timestamp("ends_at").nullable()
val bindingWindowSeconds = long("binding_window_seconds")
val inviterRewardCredits = long("inviter_reward_credits")
val inviteeRewardCredits = long("invitee_reward_credits")
val maxRewardedBindings = long("max_rewarded_bindings").nullable()
val budgetCredits = long("budget_credits").nullable()
val enabled = bool("enabled")
override val primaryKey = PrimaryKey(id)
}
private object ReferralCampaignBudgets : Table("referral_campaign_budgets") {
val campaignId = varchar("campaign_id", 36)
val rewardedBindings = long("rewarded_bindings")
val spentCredits = long("spent_credits")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(campaignId)
}
private object ReferralCodes : Table("referral_codes") {
val id = varchar("id", 36)
val ownerUserId = varchar("owner_user_id", 36)
val ownerIdentityFingerprint = char("owner_identity_fingerprint", 64).nullable()
val campaignId = varchar("campaign_id", 36)
val code = varchar("code", 32)
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object ReferralBindings : Table("referral_bindings") {
val id = varchar("id", 36)
val inviterUserId = varchar("inviter_user_id", 36)
val inviteeUserId = varchar("invitee_user_id", 36)
val codeId = varchar("code_id", 36)
val campaignId = varchar("campaign_id", 36)
val boundAt = timestamp("bound_at")
val rewardedAt = timestamp("rewarded_at").nullable()
val rewardSettlementId = varchar("reward_settlement_id", 36).nullable()
val rewardStatus = enumerationByName<ReferralRewardStatus>("reward_status", 24)
override val primaryKey = PrimaryKey(id)
}
class ExposedBillingTransactionRunner(
private val database: Database,
) : BillingTransactionRunner {
override suspend fun <T> inTransaction(block: (BillingUnitOfWork) -> T): T =
withContext(Dispatchers.IO) {
transaction(database) {
block(ExposedBillingUnitOfWork)
}
}
}
private object ExposedBillingUnitOfWork : BillingUnitOfWork {
override val credits: CreditsRepository = ExposedCreditsRepository
override val referrals: ReferralsRepository = ExposedReferralsRepository
}
private object ExposedCreditsRepository : CreditsRepository {
override fun createAccountIfAbsent(userId: UUID, now: Instant) {
CreditAccounts.insertIgnore {
it[CreditAccounts.userId] = userId.toString()
it[balance] = 0
it[updatedAt] = now
}
}
override fun lockAccount(userId: UUID): CreditAccount =
CreditAccounts
.selectAll()
.where { CreditAccounts.userId eq userId.toString() }
.forUpdate()
.singleOrNull()
?.toCreditAccount()
?: throw CreditNotFound("Credit account does not exist")
override fun updateAccountBalance(
userId: UUID,
newBalance: Long,
now: Instant,
): CreditAccount {
CreditAccounts.update({ CreditAccounts.userId eq userId.toString() }) {
it[balance] = newBalance
it[updatedAt] = now
}
return CreditAccount(userId, newBalance, now)
}
override fun findLedgerEntry(userId: UUID, idempotencyKey: String): LedgerEntry? =
CreditLedger
.selectAll()
.where {
(CreditLedger.userId eq userId.toString()) and
(CreditLedger.idempotencyKey eq idempotencyKey)
}
.singleOrNull()
?.toLedgerEntry()
override fun insertLedgerEntry(entry: LedgerEntry) {
CreditLedger.insert {
it[id] = entry.id.toString()
it[userId] = entry.userId.toString()
it[entryType] = entry.type
it[amountDelta] = entry.amountDelta
it[balanceAfter] = entry.balanceAfter
it[idempotencyKey] = entry.idempotencyKey
it[referenceId] = entry.referenceId?.toString()
it[createdAt] = entry.createdAt
}
}
override fun listLedgerEntries(userId: UUID, limit: Int): List<LedgerEntry> =
CreditLedger
.selectAll()
.where { CreditLedger.userId eq userId.toString() }
.orderBy(
CreditLedger.createdAt to SortOrder.DESC,
CreditLedger.id to SortOrder.DESC,
)
.limit(limit)
.map(ResultRow::toLedgerEntry)
override fun insertUsageRecord(record: CreditUsageRecord) {
CreditUsageRecords.insert {
it[id] = record.id.toString()
it[reservationId] = record.reservationId.toString()
it[userId] = record.userId.toString()
it[rateVersionId] = record.rateVersionId.toString()
it[usageKind] = record.usage.kind
when (val usage = record.usage) {
is UsageMeasurement.Asr -> {
it[asrMillis] = usage.durationMillis
it[inputTokens] = null
it[outputTokens] = null
}
is UsageMeasurement.Llm -> {
it[asrMillis] = null
it[inputTokens] = usage.inputTokens
it[outputTokens] = usage.outputTokens
}
}
it[chargedCredits] = record.chargedCredits
it[createdAt] = record.createdAt
}
}
override fun findReservationByReserveKey(
userId: UUID,
idempotencyKey: String,
): CreditReservation? = CreditReservations
.selectAll()
.where {
(CreditReservations.userId eq userId.toString()) and
(CreditReservations.reserveIdempotencyKey eq idempotencyKey)
}
.singleOrNull()
?.toCreditReservation()
override fun lockReservation(id: UUID): CreditReservation? =
CreditReservations
.selectAll()
.where { CreditReservations.id eq id.toString() }
.forUpdate()
.singleOrNull()
?.toCreditReservation()
override fun insertReservation(reservation: CreditReservation) {
CreditReservations.insert {
it[id] = reservation.id.toString()
it[userId] = reservation.userId.toString()
it[rateVersionId] = reservation.rateVersionId.toString()
it[provider] = reservation.provider
it[model] = reservation.model
setUsage(it, reservation.estimatedUsage, estimated = true)
it[actualAsrMillis] = null
it[actualInputTokens] = null
it[actualOutputTokens] = null
it[reservedCredits] = reservation.reservedCredits
it[settledCredits] = reservation.settledCredits
it[status] = reservation.status
it[managedCall] = reservation.managedCall
it[reserveIdempotencyKey] = reservation.reserveIdempotencyKey
it[settleIdempotencyKey] = reservation.settleIdempotencyKey
it[releaseIdempotencyKey] = reservation.releaseIdempotencyKey
it[refundIdempotencyKey] = reservation.refundIdempotencyKey
it[createdAt] = reservation.createdAt
it[updatedAt] = reservation.updatedAt
}
}
override fun updateReservation(reservation: CreditReservation) {
CreditReservations.update({ CreditReservations.id eq reservation.id.toString() }) {
reservation.actualUsage?.let { usage -> setUsage(it, usage, estimated = false) }
it[settledCredits] = reservation.settledCredits
it[status] = reservation.status
it[settleIdempotencyKey] = reservation.settleIdempotencyKey
it[releaseIdempotencyKey] = reservation.releaseIdempotencyKey
it[refundIdempotencyKey] = reservation.refundIdempotencyKey
it[updatedAt] = reservation.updatedAt
}
}
override fun findRateVersion(id: UUID): CreditRateVersion? =
CreditRateVersions
.selectAll()
.where { CreditRateVersions.id eq id.toString() }
.singleOrNull()
?.toCreditRateVersion()
override fun findEffectiveRate(
kind: UsageKind,
provider: String,
model: String,
at: Instant,
): CreditRateVersion? = CreditRateVersions
.selectAll()
.where {
(CreditRateVersions.kind eq kind) and
(CreditRateVersions.provider eq provider) and
(CreditRateVersions.model eq model) and
(CreditRateVersions.effectiveFrom lessEq at) and
(
CreditRateVersions.effectiveUntil.isNull() or
(CreditRateVersions.effectiveUntil greater at)
)
}
.orderBy(CreditRateVersions.effectiveFrom, SortOrder.DESC)
.limit(1)
.singleOrNull()
?.toCreditRateVersion()
override fun listEffectiveRates(at: Instant): List<CreditRateVersion> =
CreditRateVersions
.selectAll()
.where {
(CreditRateVersions.effectiveFrom lessEq at) and
(
CreditRateVersions.effectiveUntil.isNull() or
(CreditRateVersions.effectiveUntil greater at)
)
}
.orderBy(CreditRateVersions.provider to SortOrder.ASC)
.map(ResultRow::toCreditRateVersion)
private fun <T : Any> setUsage(
statement: org.jetbrains.exposed.v1.core.statements.UpdateBuilder<T>,
usage: UsageMeasurement,
estimated: Boolean,
) {
statement[CreditReservations.usageKind] = usage.kind
when (usage) {
is UsageMeasurement.Asr -> {
statement[
if (estimated) {
CreditReservations.estimatedAsrMillis
} else {
CreditReservations.actualAsrMillis
},
] =
usage.durationMillis
statement[
if (estimated) {
CreditReservations.estimatedInputTokens
} else {
CreditReservations.actualInputTokens
},
] = null
statement[
if (estimated) {
CreditReservations.estimatedOutputTokens
} else {
CreditReservations.actualOutputTokens
},
] = null
}
is UsageMeasurement.Llm -> {
statement[
if (estimated) {
CreditReservations.estimatedAsrMillis
} else {
CreditReservations.actualAsrMillis
},
] = null
statement[
if (estimated) {
CreditReservations.estimatedInputTokens
} else {
CreditReservations.actualInputTokens
},
] =
usage.inputTokens
statement[
if (estimated) {
CreditReservations.estimatedOutputTokens
} else {
CreditReservations.actualOutputTokens
},
] =
usage.outputTokens
}
}
}
}
private object ExposedReferralsRepository : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? {
val query = ReferralCodes
.selectAll()
.where { ReferralCodes.ownerUserId eq ownerUserId.toString() }
return if (campaignId == null) {
query.orderBy(ReferralCodes.createdAt, SortOrder.DESC).limit(1).singleOrNull()
} else {
query.andWhere { ReferralCodes.campaignId eq campaignId.toString() }.singleOrNull()
}?.toReferralCode()
}
override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? =
ReferralCodes
.selectAll()
.where {
(ReferralCodes.ownerUserId eq ownerUserId.toString()) and
(ReferralCodes.campaignId eq campaignId.toString())
}
.forUpdate()
.singleOrNull()
?.toReferralCode()
override fun findCode(code: String): ReferralCode? =
ReferralCodes
.selectAll()
.where { ReferralCodes.code eq code }
.singleOrNull()
?.toReferralCode()
override fun insertCodeIfAbsent(code: ReferralCode): Boolean =
ReferralCodes.insertIgnore {
it[id] = code.id.toString()
it[ownerUserId] = code.ownerUserId.toString()
it[ownerIdentityFingerprint] = code.ownerIdentityFingerprint
it[campaignId] = (code.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
it[ReferralCodes.code] = code.code
it[createdAt] = code.createdAt
}.insertedCount == 1
override fun findCampaign(id: UUID): ReferralCampaign? =
ReferralCampaigns
.selectAll()
.where { ReferralCampaigns.id eq id.toString() }
.singleOrNull()
?.toReferralCampaign()
override fun listActiveCampaigns(at: Instant): List<ReferralCampaign> =
ReferralCampaigns
.selectAll()
.where {
(ReferralCampaigns.enabled eq true) and
(ReferralCampaigns.startsAt lessEq at) and
(
ReferralCampaigns.endsAt.isNull() or
(ReferralCampaigns.endsAt greater at)
)
}
.orderBy(ReferralCampaigns.startsAt, SortOrder.DESC)
.map(ResultRow::toReferralCampaign)
override fun lockCampaignBudget(campaignId: UUID): ReferralCampaignBudget =
ReferralCampaignBudgets
.selectAll()
.where { ReferralCampaignBudgets.campaignId eq campaignId.toString() }
.forUpdate()
.single()
.toReferralCampaignBudget()
override fun updateCampaignBudget(budget: ReferralCampaignBudget) {
ReferralCampaignBudgets.update({
ReferralCampaignBudgets.campaignId eq budget.campaignId.toString()
}) {
it[rewardedBindings] = budget.rewardedBindings
it[spentCredits] = budget.spentCredits
it[updatedAt] = budget.updatedAt
}
}
override fun findBinding(inviteeUserId: UUID): ReferralBinding? =
ReferralBindings
.selectAll()
.where { ReferralBindings.inviteeUserId eq inviteeUserId.toString() }
.singleOrNull()
?.toReferralBinding()
override fun listBindingsByInviter(
inviterUserId: UUID,
limit: Int,
): List<ReferralBinding> =
ReferralBindings
.selectAll()
.where { ReferralBindings.inviterUserId eq inviterUserId.toString() }
.orderBy(ReferralBindings.boundAt, SortOrder.DESC)
.limit(limit)
.map(ResultRow::toReferralBinding)
override fun lockBinding(inviteeUserId: UUID): ReferralBinding? =
ReferralBindings
.selectAll()
.where { ReferralBindings.inviteeUserId eq inviteeUserId.toString() }
.forUpdate()
.singleOrNull()
?.toReferralBinding()
override fun insertBindingIfAbsent(binding: ReferralBinding): Boolean =
ReferralBindings.insertIgnore {
it[id] = binding.id.toString()
it[inviterUserId] = binding.inviterUserId.toString()
it[inviteeUserId] = binding.inviteeUserId.toString()
it[codeId] = binding.codeId.toString()
it[campaignId] = (binding.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
it[boundAt] = binding.boundAt
it[rewardedAt] = binding.rewardedAt
it[rewardSettlementId] = binding.rewardSettlementId?.toString()
it[rewardStatus] = binding.rewardStatus
}.insertedCount == 1
override fun markRewarded(bindingId: UUID, settlementId: UUID, rewardedAt: Instant) {
ReferralBindings.update({ ReferralBindings.id eq bindingId.toString() }) {
it[ReferralBindings.rewardedAt] = rewardedAt
it[rewardSettlementId] = settlementId.toString()
it[rewardStatus] = ReferralRewardStatus.REWARDED
}
}
override fun markRewardIneligible(bindingId: UUID) {
ReferralBindings.update({ ReferralBindings.id eq bindingId.toString() }) {
it[rewardStatus] = ReferralRewardStatus.INELIGIBLE_BUDGET
}
}
}
private fun ResultRow.toCreditAccount() = CreditAccount(
userId = UUID.fromString(this[CreditAccounts.userId]),
balance = this[CreditAccounts.balance],
updatedAt = this[CreditAccounts.updatedAt],
)
private fun ResultRow.toLedgerEntry() = LedgerEntry(
id = UUID.fromString(this[CreditLedger.id]),
userId = UUID.fromString(this[CreditLedger.userId]),
type = this[CreditLedger.entryType],
amountDelta = this[CreditLedger.amountDelta],
balanceAfter = this[CreditLedger.balanceAfter],
idempotencyKey = this[CreditLedger.idempotencyKey],
referenceId = this[CreditLedger.referenceId]?.let(UUID::fromString),
createdAt = this[CreditLedger.createdAt],
)
private fun ResultRow.toCreditRateVersion() = CreditRateVersion(
id = UUID.fromString(this[CreditRateVersions.id]),
kind = this[CreditRateVersions.kind],
provider = this[CreditRateVersions.provider],
model = this[CreditRateVersions.model],
effectiveFrom = this[CreditRateVersions.effectiveFrom],
effectiveUntil = this[CreditRateVersions.effectiveUntil],
asrCreditsNumerator = this[CreditRateVersions.asrCreditsNumerator],
asrMillisDenominator = this[CreditRateVersions.asrMillisDenominator],
inputCreditsNumerator = this[CreditRateVersions.inputCreditsNumerator],
inputTokensDenominator = this[CreditRateVersions.inputTokensDenominator],
outputCreditsNumerator = this[CreditRateVersions.outputCreditsNumerator],
outputTokensDenominator = this[CreditRateVersions.outputTokensDenominator],
)
private fun ResultRow.toCreditReservation(): CreditReservation {
val kind = this[CreditReservations.usageKind]
val estimated = when (kind) {
UsageKind.ASR -> UsageMeasurement.Asr(requireNotNull(this[CreditReservations.estimatedAsrMillis]))
UsageKind.LLM -> UsageMeasurement.Llm(
requireNotNull(this[CreditReservations.estimatedInputTokens]),
requireNotNull(this[CreditReservations.estimatedOutputTokens]),
)
}
val actual = when {
this[CreditReservations.actualAsrMillis] != null ->
UsageMeasurement.Asr(requireNotNull(this[CreditReservations.actualAsrMillis]))
this[CreditReservations.actualInputTokens] != null ->
UsageMeasurement.Llm(
requireNotNull(this[CreditReservations.actualInputTokens]),
requireNotNull(this[CreditReservations.actualOutputTokens]),
)
else -> null
}
return CreditReservation(
id = UUID.fromString(this[CreditReservations.id]),
userId = UUID.fromString(this[CreditReservations.userId]),
rateVersionId = UUID.fromString(this[CreditReservations.rateVersionId]),
provider = this[CreditReservations.provider],
model = this[CreditReservations.model],
estimatedUsage = estimated,
actualUsage = actual,
reservedCredits = this[CreditReservations.reservedCredits],
settledCredits = this[CreditReservations.settledCredits],
status = this[CreditReservations.status],
managedCall = this[CreditReservations.managedCall],
reserveIdempotencyKey = this[CreditReservations.reserveIdempotencyKey],
settleIdempotencyKey = this[CreditReservations.settleIdempotencyKey],
releaseIdempotencyKey = this[CreditReservations.releaseIdempotencyKey],
refundIdempotencyKey = this[CreditReservations.refundIdempotencyKey],
createdAt = this[CreditReservations.createdAt],
updatedAt = this[CreditReservations.updatedAt],
)
}
private fun ResultRow.toReferralCode() = ReferralCode(
id = UUID.fromString(this[ReferralCodes.id]),
ownerUserId = UUID.fromString(this[ReferralCodes.ownerUserId]),
ownerIdentityFingerprint = this[ReferralCodes.ownerIdentityFingerprint],
code = this[ReferralCodes.code],
createdAt = this[ReferralCodes.createdAt],
campaignId = UUID.fromString(this[ReferralCodes.campaignId]),
)
private fun ResultRow.toReferralCampaign() = ReferralCampaign(
id = UUID.fromString(this[ReferralCampaigns.id]),
name = this[ReferralCampaigns.name],
startsAt = this[ReferralCampaigns.startsAt],
endsAt = this[ReferralCampaigns.endsAt],
bindingWindowSeconds = this[ReferralCampaigns.bindingWindowSeconds],
inviterRewardCredits = this[ReferralCampaigns.inviterRewardCredits],
inviteeRewardCredits = this[ReferralCampaigns.inviteeRewardCredits],
maxRewardedBindings = this[ReferralCampaigns.maxRewardedBindings],
budgetCredits = this[ReferralCampaigns.budgetCredits],
enabled = this[ReferralCampaigns.enabled],
)
private fun ResultRow.toReferralCampaignBudget() = ReferralCampaignBudget(
campaignId = UUID.fromString(this[ReferralCampaignBudgets.campaignId]),
rewardedBindings = this[ReferralCampaignBudgets.rewardedBindings],
spentCredits = this[ReferralCampaignBudgets.spentCredits],
updatedAt = this[ReferralCampaignBudgets.updatedAt],
)
private fun ResultRow.toReferralBinding() = ReferralBinding(
id = UUID.fromString(this[ReferralBindings.id]),
inviterUserId = UUID.fromString(this[ReferralBindings.inviterUserId]),
inviteeUserId = UUID.fromString(this[ReferralBindings.inviteeUserId]),
codeId = UUID.fromString(this[ReferralBindings.codeId]),
boundAt = this[ReferralBindings.boundAt],
rewardedAt = this[ReferralBindings.rewardedAt],
rewardSettlementId = this[ReferralBindings.rewardSettlementId]?.let(UUID::fromString),
campaignId = UUID.fromString(this[ReferralBindings.campaignId]),
rewardStatus = this[ReferralBindings.rewardStatus],
)
@@ -0,0 +1,94 @@
package com.osglab.account.features.credits.routes
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditException
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InsufficientCredits
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.models.CreditAccountDto
import com.osglab.account.features.credits.models.CreditErrorDto
import com.osglab.account.features.credits.models.CreditRateVersionDto
import com.osglab.account.features.credits.models.LedgerEntryDto
import com.osglab.account.features.credits.services.CreditOperations
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.principal
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import java.util.UUID
fun interface AuthenticatedUserExtractor {
suspend fun extract(call: ApplicationCall): UUID?
}
object JwtSubjectUserExtractor : AuthenticatedUserExtractor {
override suspend fun extract(call: ApplicationCall): UUID? = call.jwtSubjectUserId()
}
class CreditRouteInstaller(
private val service: CreditOperations,
private val authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
fun install(parent: Route) {
parent.route("/v1/credits") {
get("/balance") {
call.creditCall(authenticatedUser) { userId ->
CreditAccountDto.fromDomain(service.getAccount(userId))
}
}
get("/ledger") {
call.creditCall(authenticatedUser) { userId ->
val rawLimit = call.request.queryParameters["limit"]
val limit = rawLimit?.toIntOrNull()
?: if (rawLimit == null) 50 else {
throw InvalidCreditRequest("Ledger limit must be an integer")
}
service.listLedger(userId, limit).map(LedgerEntryDto::fromDomain)
}
}
get("/rates") {
call.creditCall(authenticatedUser) {
service.listEffectiveRates().map(CreditRateVersionDto::fromDomain)
}
}
}
}
}
fun Route.creditRoutes(
service: CreditOperations,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
CreditRouteInstaller(service, authenticatedUser).install(this)
}
/** Reads the principal produced by the session authentication boundary. */
internal fun ApplicationCall.jwtSubjectUserId(): UUID? =
principal<AccountPrincipal>()?.userId
private suspend fun ApplicationCall.creditCall(
authenticatedUser: AuthenticatedUserExtractor,
block: suspend (UUID) -> Any,
) {
val userId = authenticatedUser.extract(this)
if (userId == null) {
respond(HttpStatusCode.Unauthorized, CreditErrorDto("Authentication required"))
return
}
try {
respond(block(userId))
} catch (exception: InsufficientCredits) {
respond(HttpStatusCode.PaymentRequired, CreditErrorDto(exception.message.orEmpty()))
} catch (exception: InvalidCreditRequest) {
respond(HttpStatusCode.BadRequest, CreditErrorDto(exception.message.orEmpty()))
} catch (exception: CreditNotFound) {
respond(HttpStatusCode.NotFound, CreditErrorDto(exception.message.orEmpty()))
} catch (exception: CreditConflict) {
respond(HttpStatusCode.Conflict, CreditErrorDto(exception.message.orEmpty()))
} catch (exception: CreditException) {
respond(HttpStatusCode.UnprocessableEntity, CreditErrorDto(exception.message.orEmpty()))
}
}
@@ -0,0 +1,572 @@
package com.osglab.account.features.credits.services
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditCostCalculator
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.InsufficientCredits
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.domain.ReservationStatus
import com.osglab.account.features.credits.domain.ReservationStateRules
import com.osglab.account.features.credits.domain.UsageMeasurement
import com.osglab.account.features.credits.domain.validatedIdempotencyKey
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.credits.repositories.BillingUnitOfWork
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import java.time.Clock
import java.time.Instant
import java.util.UUID
data class ReferralRewardConfig(
val inviterCredits: Long,
val inviteeCredits: Long,
) {
init {
require(inviterCredits > 0) { "Inviter reward must be positive" }
require(inviteeCredits > 0) { "Invitee reward must be positive" }
}
}
/**
* Public boundary used by routes, gateway adapters and account provisioning.
* Implementations must preserve transactionality and idempotency guarantees.
*/
interface CreditOperations {
suspend fun getAccount(userId: UUID): CreditAccount
suspend fun listEffectiveRates(): List<CreditRateVersion>
suspend fun listLedger(userId: UUID, limit: Int = 50): List<LedgerEntry>
suspend fun getReservation(userId: UUID, reservationId: UUID): CreditReservation
suspend fun grantSignupTrial(
userId: UUID,
credits: Long,
idempotencyKey: String,
): CreditAccount
suspend fun reserve(
userId: UUID,
provider: String,
model: String,
estimatedUsage: UsageMeasurement,
managedCall: Boolean,
idempotencyKey: String,
): CreditReservation
suspend fun settle(
userId: UUID,
reservationId: UUID,
actualUsage: UsageMeasurement,
idempotencyKey: String,
): CreditReservation
suspend fun release(
userId: UUID,
reservationId: UUID,
idempotencyKey: String,
): CreditReservation
suspend fun refund(
userId: UUID,
reservationId: UUID,
idempotencyKey: String,
): CreditReservation
}
class CreditService(
private val transactions: BillingTransactionRunner,
private val referralRewards: ReferralRewardConfig,
private val clock: Clock = Clock.systemUTC(),
private val newId: () -> UUID = UUID::randomUUID,
) : CreditOperations {
override suspend fun getAccount(userId: UUID): CreditAccount = transactions.inTransaction { unit ->
unit.credits.createAccountIfAbsent(userId, clock.instant())
unit.credits.lockAccount(userId)
}
override suspend fun listEffectiveRates(): List<CreditRateVersion> =
transactions.inTransaction { it.credits.listEffectiveRates(clock.instant()) }
override suspend fun listLedger(userId: UUID, limit: Int): List<LedgerEntry> {
if (limit !in 1..100) throw InvalidCreditRequest("Ledger limit must be between 1 and 100")
return transactions.inTransaction { it.credits.listLedgerEntries(userId, limit) }
}
override suspend fun getReservation(
userId: UUID,
reservationId: UUID,
): CreditReservation =
transactions.inTransaction { unit ->
requireOwnedReservation(unit, userId, reservationId)
}
/**
* Trusted internal adapters recover ownership from an opaque reservation ID.
* User-facing routes must call the ownership-checking overload above.
*/
suspend fun getReservation(reservationId: UUID): CreditReservation =
transactions.inTransaction { unit ->
unit.credits.lockReservation(reservationId)
?: throw CreditNotFound("Reservation does not exist")
}
override suspend fun grantSignupTrial(
userId: UUID,
credits: Long,
idempotencyKey: String,
): CreditAccount {
if (credits <= 0) throw InvalidCreditRequest("Signup trial credits must be positive")
val key = validatedIdempotencyKey(idempotencyKey)
return transactions.inTransaction { unit ->
val now = clock.instant()
unit.credits.createAccountIfAbsent(userId, now)
val account = unit.credits.lockAccount(userId)
val existing = unit.credits.findLedgerEntry(userId, key)
if (existing != null) {
requireIdempotentLedger(existing, LedgerEntryType.SIGNUP_TRIAL, credits, null)
return@inTransaction account
}
applyLedgerDelta(
unit = unit,
account = account,
delta = credits,
type = LedgerEntryType.SIGNUP_TRIAL,
key = key,
referenceId = null,
now = now,
)
}
}
override suspend fun reserve(
userId: UUID,
provider: String,
model: String,
estimatedUsage: UsageMeasurement,
managedCall: Boolean,
idempotencyKey: String,
): CreditReservation {
val normalizedProvider = validatedName(provider, "Provider")
val normalizedModel = validatedName(model, "Model")
val key = validatedIdempotencyKey(idempotencyKey)
return transactions.inTransaction { unit ->
val now = clock.instant()
unit.credits.createAccountIfAbsent(userId, now)
val account = unit.credits.lockAccount(userId)
unit.credits.findReservationByReserveKey(userId, key)?.let { existing ->
if (existing.provider != normalizedProvider ||
existing.model != normalizedModel ||
existing.estimatedUsage != estimatedUsage ||
existing.managedCall != managedCall
) {
throw CreditConflict("Idempotency key was already used with a different reservation")
}
return@inTransaction existing
}
ensureUnusedLedgerKey(unit, userId, key)
val rate = unit.credits.findEffectiveRate(
estimatedUsage.kind,
normalizedProvider,
normalizedModel,
now,
) ?: throw CreditNotFound("No effective rate exists for this provider and model")
val required = calculatePositiveCost(rate, estimatedUsage, "Estimated usage")
if (account.balance < required) {
throw InsufficientCredits(account.balance, required)
}
val reservationId = newId()
applyLedgerDelta(
unit = unit,
account = account,
delta = -required,
type = LedgerEntryType.USAGE_RESERVE,
key = key,
referenceId = reservationId,
now = now,
)
CreditReservation(
id = reservationId,
userId = userId,
rateVersionId = rate.id,
provider = normalizedProvider,
model = normalizedModel,
estimatedUsage = estimatedUsage,
actualUsage = null,
reservedCredits = required,
settledCredits = null,
status = ReservationStatus.RESERVED,
managedCall = managedCall,
reserveIdempotencyKey = key,
settleIdempotencyKey = null,
releaseIdempotencyKey = null,
refundIdempotencyKey = null,
createdAt = now,
updatedAt = now,
).also(unit.credits::insertReservation)
}
}
override suspend fun settle(
userId: UUID,
reservationId: UUID,
actualUsage: UsageMeasurement,
idempotencyKey: String,
): CreditReservation {
val key = validatedIdempotencyKey(idempotencyKey)
return transactions.inTransaction { unit ->
val now = clock.instant()
val reservation = requireOwnedReservation(unit, userId, reservationId)
if (reservation.status == ReservationStatus.SETTLED &&
reservation.settleIdempotencyKey == key
) {
if (reservation.actualUsage != actualUsage) {
throw CreditConflict(
"Idempotency key was already used with different actual usage",
)
}
return@inTransaction reservation
}
requireReserved(reservation, "settle")
if (reservation.estimatedUsage.kind != actualUsage.kind) {
throw InvalidCreditRequest("Actual usage kind differs from reserved usage kind")
}
val rate = unit.credits.findRateVersion(reservation.rateVersionId)
?: throw CreditNotFound("Reserved rate version no longer exists")
val actualCredits = CreditCostCalculator.calculate(rate, actualUsage)
val binding = if (reservation.managedCall && actualCredits > 0) {
unit.referrals.lockBinding(userId)?.takeIf {
it.rewardStatus == ReferralRewardStatus.PENDING
}
} else {
null
}
val rewardPlan = binding?.let { prepareReferralReward(unit, it, now) }
val accountIds = buildSet {
add(userId)
rewardPlan?.let { add(it.binding.inviterUserId) }
}.sortedBy(UUID::toString)
accountIds.forEach { unit.credits.createAccountIfAbsent(it, now) }
val accounts = accountIds.associateWith(unit.credits::lockAccount).toMutableMap()
ensureUnusedLedgerKey(unit, userId, key)
val account = requireNotNull(accounts[userId])
val settlementDelta = Math.subtractExact(reservation.reservedCredits, actualCredits)
val resultingBalance = Math.addExact(account.balance, settlementDelta)
if (resultingBalance < 0) {
throw InsufficientCredits(account.balance, -settlementDelta)
}
accounts[userId] = applyLedgerDelta(
unit = unit,
account = account,
delta = settlementDelta,
type = LedgerEntryType.USAGE_SETTLE,
key = key,
referenceId = reservation.id,
now = now,
)
val settled = reservation.copy(
actualUsage = actualUsage,
settledCredits = actualCredits,
status = ReservationStatus.SETTLED,
settleIdempotencyKey = key,
updatedAt = now,
)
unit.credits.updateReservation(settled)
unit.credits.insertUsageRecord(
CreditUsageRecord(
id = newId(),
reservationId = reservation.id,
userId = userId,
rateVersionId = rate.id,
usage = actualUsage,
chargedCredits = actualCredits,
createdAt = now,
),
)
if (rewardPlan != null) {
grantReferralRewards(
unit = unit,
bindingId = rewardPlan.binding.id,
inviterUserId = rewardPlan.binding.inviterUserId,
inviteeUserId = rewardPlan.binding.inviteeUserId,
settlementId = reservation.id,
inviterCredits = rewardPlan.inviterCredits,
inviteeCredits = rewardPlan.inviteeCredits,
lockedAccounts = accounts,
now = now,
)
}
settled
}
}
override suspend fun release(
userId: UUID,
reservationId: UUID,
idempotencyKey: String,
): CreditReservation = terminalCreditOperation(
userId = userId,
reservationId = reservationId,
idempotencyKey = idempotencyKey,
targetStatus = ReservationStatus.RELEASED,
ledgerType = LedgerEntryType.USAGE_RELEASE,
amount = { it.reservedCredits },
existingKey = { it.releaseIdempotencyKey },
update = { reservation, key, now ->
reservation.copy(
status = ReservationStatus.RELEASED,
releaseIdempotencyKey = key,
updatedAt = now,
)
},
allowedStatus = ReservationStatus.RESERVED,
)
override suspend fun refund(
userId: UUID,
reservationId: UUID,
idempotencyKey: String,
): CreditReservation = terminalCreditOperation(
userId = userId,
reservationId = reservationId,
idempotencyKey = idempotencyKey,
targetStatus = ReservationStatus.REFUNDED,
ledgerType = LedgerEntryType.USAGE_REFUND,
amount = { requireNotNull(it.settledCredits) },
existingKey = { it.refundIdempotencyKey },
update = { reservation, key, now ->
reservation.copy(
status = ReservationStatus.REFUNDED,
refundIdempotencyKey = key,
updatedAt = now,
)
},
allowedStatus = ReservationStatus.SETTLED,
)
private suspend fun terminalCreditOperation(
userId: UUID,
reservationId: UUID,
idempotencyKey: String,
targetStatus: ReservationStatus,
ledgerType: LedgerEntryType,
amount: (CreditReservation) -> Long,
existingKey: (CreditReservation) -> String?,
update: (CreditReservation, String, Instant) -> CreditReservation,
allowedStatus: ReservationStatus,
): CreditReservation {
val key = validatedIdempotencyKey(idempotencyKey)
return transactions.inTransaction { unit ->
val now = clock.instant()
val reservation = requireOwnedReservation(unit, userId, reservationId)
if (reservation.status == targetStatus && existingKey(reservation) == key) {
return@inTransaction reservation
}
if (reservation.status != allowedStatus ||
!ReservationStateRules.canTransition(reservation.status, targetStatus)
) {
throw CreditConflict(
"Reservation in ${reservation.status} state cannot become $targetStatus",
)
}
val account = unit.credits.lockAccount(userId)
ensureUnusedLedgerKey(unit, userId, key)
applyLedgerDelta(
unit = unit,
account = account,
delta = amount(reservation),
type = ledgerType,
key = key,
referenceId = reservation.id,
now = now,
)
update(reservation, key, now).also(unit.credits::updateReservation)
}
}
private fun grantReferralRewards(
unit: BillingUnitOfWork,
bindingId: UUID,
inviterUserId: UUID,
inviteeUserId: UUID,
settlementId: UUID,
inviterCredits: Long,
inviteeCredits: Long,
lockedAccounts: Map<UUID, CreditAccount>,
now: Instant,
) {
val inviteeKey = "internal:referral:$bindingId:invitee"
val inviterKey = "internal:referral:$bindingId:inviter"
applyLedgerDelta(
unit,
requireNotNull(lockedAccounts[inviteeUserId]),
inviteeCredits,
LedgerEntryType.REFERRAL_INVITEE,
inviteeKey,
bindingId,
now,
)
applyLedgerDelta(
unit,
requireNotNull(lockedAccounts[inviterUserId]),
inviterCredits,
LedgerEntryType.REFERRAL_INVITER,
inviterKey,
bindingId,
now,
)
unit.referrals.markRewarded(bindingId, settlementId, now)
}
private fun prepareReferralReward(
unit: BillingUnitOfWork,
binding: ReferralBinding,
now: Instant,
): ReferralRewardPlan? {
val campaignId = binding.campaignId
?: return ReferralRewardPlan(
binding,
referralRewards.inviterCredits,
referralRewards.inviteeCredits,
)
val campaign = unit.referrals.findCampaign(campaignId)
?: throw CreditNotFound("Referral campaign no longer exists")
val budget = unit.referrals.lockCampaignBudget(campaignId)
val nextCount = addOrNull(budget.rewardedBindings, 1)
val nextSpent = addOrNull(budget.spentCredits, campaign.rewardCost)
if (nextCount == null || nextSpent == null) {
unit.referrals.markRewardIneligible(binding.id)
return null
}
val withinCount = campaign.maxRewardedBindings?.let { nextCount <= it } ?: true
val withinBudget = campaign.budgetCredits?.let { nextSpent <= it } ?: true
if (!withinCount || !withinBudget) {
unit.referrals.markRewardIneligible(binding.id)
return null
}
unit.referrals.updateCampaignBudget(
budget.copy(
rewardedBindings = nextCount,
spentCredits = nextSpent,
updatedAt = now,
),
)
return ReferralRewardPlan(
binding,
campaign.inviterRewardCredits,
campaign.inviteeRewardCredits,
)
}
private fun requireOwnedReservation(
unit: BillingUnitOfWork,
userId: UUID,
reservationId: UUID,
): CreditReservation {
val reservation = unit.credits.lockReservation(reservationId)
?: throw CreditNotFound("Reservation does not exist")
if (reservation.userId != userId) throw CreditNotFound("Reservation does not exist")
return reservation
}
private fun requireReserved(reservation: CreditReservation, operation: String) {
if (!ReservationStateRules.canTransition(reservation.status, ReservationStatus.SETTLED)) {
throw CreditConflict("Reservation in ${reservation.status} state cannot be $operation")
}
}
private fun ensureUnusedLedgerKey(
unit: BillingUnitOfWork,
userId: UUID,
key: String,
) {
if (unit.credits.findLedgerEntry(userId, key) != null) {
throw CreditConflict("Idempotency key was already used by another credit operation")
}
}
private fun applyLedgerDelta(
unit: BillingUnitOfWork,
account: CreditAccount,
delta: Long,
type: LedgerEntryType,
key: String,
referenceId: UUID?,
now: Instant,
): CreditAccount {
val newBalance = try {
Math.addExact(account.balance, delta)
} catch (_: ArithmeticException) {
throw InvalidCreditRequest("Credit balance exceeds the supported integer range")
}
if (newBalance < 0) throw InsufficientCredits(account.balance, -delta)
unit.credits.insertLedgerEntry(
LedgerEntry(
id = newId(),
userId = account.userId,
type = type,
amountDelta = delta,
balanceAfter = newBalance,
idempotencyKey = key,
referenceId = referenceId,
createdAt = now,
),
)
return unit.credits.updateAccountBalance(account.userId, newBalance, now)
}
private fun requireIdempotentLedger(
existing: LedgerEntry,
expectedType: LedgerEntryType,
expectedDelta: Long,
expectedReferenceId: UUID?,
) {
if (existing.type != expectedType ||
existing.amountDelta != expectedDelta ||
existing.referenceId != expectedReferenceId
) {
throw CreditConflict("Idempotency key was already used with different parameters")
}
}
private fun calculatePositiveCost(
rate: CreditRateVersion,
usage: UsageMeasurement,
label: String,
): Long {
val amount = CreditCostCalculator.calculate(rate, usage)
if (amount <= 0) throw InvalidCreditRequest("$label must cost at least one credit")
return amount
}
private fun validatedName(value: String, label: String): String {
val normalized = value.trim()
if (normalized.isEmpty() || normalized.length > 100) {
throw InvalidCreditRequest("$label must contain 1 to 100 characters")
}
return normalized
}
private fun addOrNull(left: Long, right: Long): Long? =
try {
Math.addExact(left, right)
} catch (_: ArithmeticException) {
null
}
private data class ReferralRewardPlan(
val binding: ReferralBinding,
val inviterCredits: Long,
val inviteeCredits: Long,
)
}
@@ -0,0 +1,49 @@
package com.osglab.account.features.gateway
import com.osglab.account.features.gateway.asr.AsrStreamingLimits
import java.time.Duration
/**
* Environment-backed settings supplied by the host application. Provider
* endpoints, models, policies and credentials are never accepted from clients.
*/
data class GatewaySettings(
val issuer: String,
val audience: String,
val accessTokenHmacSecret: ByteArray,
val refreshTokenHmacSecret: ByteArray,
val accessTokenLifetime: Duration = Duration.ofMinutes(5),
val refreshTokenLifetime: Duration = Duration.ofDays(30),
val maximumGrantLifetime: Duration = Duration.ofDays(90),
val llmProviderTimeout: Duration = Duration.ofMinutes(2),
val asrProviderTimeout: Duration = Duration.ofMinutes(6),
val deepSeek: DeepSeekSettings? = null,
val volcengine: VolcengineSettings? = null,
val asrLimits: AsrStreamingLimits = AsrStreamingLimits(),
) {
init {
require(issuer.isNotBlank())
require(audience.isNotBlank())
require(accessTokenHmacSecret.size >= 32)
require(refreshTokenHmacSecret.size >= 32)
require(accessTokenLifetime > Duration.ZERO)
require(refreshTokenLifetime > Duration.ZERO)
require(maximumGrantLifetime >= accessTokenLifetime)
require(llmProviderTimeout > Duration.ZERO)
require(asrProviderTimeout > Duration.ZERO)
}
}
data class DeepSeekSettings(
val endpoint: String,
val apiKey: String,
val model: String,
)
data class VolcengineSettings(
val endpoint: String,
val resourceId: String,
val appId: String? = null,
val accessToken: String? = null,
val apiKey: String? = null,
)
@@ -0,0 +1,172 @@
package com.osglab.account.features.gateway.adapters
import com.osglab.account.features.credits.domain.UsageMeasurement
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.auth.SessionAccessAuthenticator
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewaySubject
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.CreditReservationPort
import com.osglab.account.features.gateway.ports.GatewayIdentityPort
import com.osglab.account.features.gateway.ports.ProviderUsageEstimate
import io.ktor.http.HttpHeaders
import io.ktor.server.application.ApplicationCall
import java.util.UUID
class SessionIdentityAdapter(
private val sessionAuthenticator: SessionAccessAuthenticator,
) : GatewayIdentityPort, AuthenticatedUserExtractor {
override suspend fun resolve(call: ApplicationCall): GatewaySubject? =
principal(call)?.let {
// The host session may mint a grant, while the resulting gateway
// token remains limited to the scopes selected by that grant.
GatewaySubject(
userId = it.accountId.toString(),
scopes = GatewayCapability.entries.toSet(),
)
}
override suspend fun extract(call: ApplicationCall): UUID? = principal(call)?.accountId
private suspend fun principal(call: ApplicationCall) =
call.request.headers[HttpHeaders.Authorization]
?.takeIf { it.startsWith(BEARER_PREFIX, ignoreCase = true) }
?.substring(BEARER_PREFIX.length)
?.trim()
?.takeIf(String::isNotEmpty)
?.let { sessionAuthenticator.authenticate(it) }
private companion object {
const val BEARER_PREFIX = "Bearer "
}
}
class CreditReservationAdapter(
private val creditService: CreditService,
private val llmModel: String,
private val asrModel: String,
) : CreditReservationPort {
override suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
): CreditReservation = reserve(
accountId = accountId,
estimate = ProviderUsageEstimate(
meter = meter,
units = estimatedUnits,
inputUnits = estimatedUnits.takeIf { meter == UsageMeter.LLM_TOKEN },
outputUnits = 0L.takeIf { meter == UsageMeter.LLM_TOKEN },
),
requestId = requestId,
)
override suspend fun reserve(
accountId: String,
estimate: ProviderUsageEstimate,
requestId: String,
): CreditReservation {
val userId = accountId.toUuid()
val usage = estimate.toUsage()
val (provider, model) = providerAndModel(estimate.meter)
val reservation = creditService.reserve(
userId = userId,
provider = provider,
model = model,
estimatedUsage = usage,
managedCall = true,
idempotencyKey = "internal:gateway-reserve:$accountId:$requestId",
)
return CreditReservation(
id = reservation.id.toString(),
reservedUnits = reservation.reservedCredits,
)
}
override suspend fun settle(reservationId: String, actualUnits: Long) {
settle(
reservationId,
ProviderUsage(UsageMeter.AUDIO_MILLISECOND, actualUnits),
)
}
override suspend fun settle(reservationId: String, usage: ProviderUsage) {
val id = reservationId.toUuid()
val reservation = creditService.getReservation(id)
creditService.settle(
userId = reservation.userId,
reservationId = id,
actualUsage = reservation.estimatedUsage.kind.let { kind ->
when (kind) {
com.osglab.account.features.credits.domain.UsageKind.ASR ->
UsageMeasurement.Asr(usage.units)
com.osglab.account.features.credits.domain.UsageKind.LLM ->
UsageMeasurement.Llm(
inputTokens = requireNotNull(usage.inputUnits) {
"LLM provider usage omitted input tokens"
},
outputTokens = requireNotNull(usage.outputUnits) {
"LLM provider usage omitted output tokens"
},
)
}
},
idempotencyKey = "internal:gateway-settle:$reservationId",
)
}
override suspend fun release(reservationId: String) {
val id = reservationId.toUuid()
val reservation = creditService.getReservation(id)
creditService.release(
userId = reservation.userId,
reservationId = id,
idempotencyKey = "internal:gateway-release:$reservationId",
)
}
override suspend fun refund(reservationId: String) {
val id = reservationId.toUuid()
val reservation = creditService.getReservation(id)
creditService.refund(
userId = reservation.userId,
reservationId = id,
idempotencyKey = "internal:gateway-refund:$reservationId",
)
}
private fun providerAndModel(meter: UsageMeter): Pair<String, String> = when (meter) {
UsageMeter.LLM_TOKEN -> DEEPSEEK_PROVIDER to llmModel
UsageMeter.AUDIO_MILLISECOND -> VOLCENGINE_PROVIDER to asrModel
}
private fun ProviderUsageEstimate.toUsage(): UsageMeasurement {
require(units >= 0) { "Estimated usage cannot be negative" }
return when (meter) {
UsageMeter.LLM_TOKEN -> UsageMeasurement.Llm(
inputTokens = requireNotNull(inputUnits) {
"LLM usage estimate omitted input tokens"
},
outputTokens = requireNotNull(outputUnits) {
"LLM usage estimate omitted output tokens"
},
)
UsageMeter.AUDIO_MILLISECOND -> UsageMeasurement.Asr(units)
}
}
private fun String.toUuid(): UUID =
runCatching { UUID.fromString(this) }
.getOrElse { throw IllegalArgumentException("Account or reservation ID is invalid") }
private companion object {
const val DEEPSEEK_PROVIDER = "deepseek"
const val VOLCENGINE_PROVIDER = "volcengine-sauc-v3"
}
}
@@ -0,0 +1,21 @@
package com.osglab.account.features.gateway.agent
import kotlinx.serialization.Serializable
/**
* A declarative plan only. It intentionally has no URL, executable command,
* tool invocation, or provider-controlled action payload.
*/
@Serializable
data class AgentPlan(
val summary: String,
val steps: List<AgentStep>,
val warnings: List<String> = emptyList(),
)
@Serializable
data class AgentStep(
val id: String,
val title: String,
val description: String,
)
@@ -0,0 +1,328 @@
package com.osglab.account.features.gateway.asr
import com.osglab.account.features.gateway.models.AsrGatewayOptions
import com.osglab.account.features.gateway.models.AsrProviderRequest
import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.providers.volcengine.VolcengineUsageException
import com.osglab.account.features.gateway.services.GatewayService
import com.osglab.account.features.gateway.services.PreparedGatewayRequest
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import kotlin.time.TimeSource
typealias KtorVolcengineStreamingClient =
com.osglab.account.features.gateway.providers.volcengine.KtorVolcengineAsrTransport
typealias VolcengineStreamingClient =
com.osglab.account.features.gateway.providers.volcengine.VolcengineStreamingClient
@Serializable
data class CreateAsrSessionRequest(
val format: String = "pcm",
val codec: String = "raw",
val sampleRate: Int = 16_000,
val bits: Int = 16,
val channels: Int = 1,
val language: String? = null,
val estimatedDurationMillis: Long,
)
@Serializable
data class CreateAsrSessionResponse(
val sessionId: String,
val websocketPath: String,
val maxFrameBytes: Int,
val idleTimeoutMillis: Long,
)
data class AsrStreamingLimits(
val maxFrameBytes: Int = GatewayLimits.MAX_AUDIO_FRAME_BYTES,
val maxFrames: Int = GatewayLimits.MAX_AUDIO_FRAMES,
val maxAudioBytes: Long = GatewayLimits.MAX_AUDIO_BYTES.toLong(),
val maxDurationMillis: Long = GatewayLimits.MAX_AUDIO_MILLIS,
val maxConcurrentSessionsPerUser: Int = 2,
val idleTimeoutMillis: Long = 15_000,
val connectTimeoutMillis: Long = 30_000,
) {
init {
require(maxFrameBytes > 0)
require(maxFrames > 0)
require(maxAudioBytes > 0)
require(maxDurationMillis > 0)
require(maxConcurrentSessionsPerUser > 0)
require(idleTimeoutMillis > 0)
require(connectTimeoutMillis > 0)
}
}
/**
* Owns only short-lived session metadata. Audio frames are forwarded from the
* downstream flow to the upstream client and are never written to disk.
*/
class AsrStreamingService(
private val gateway: GatewayService,
private val upstream: VolcengineStreamingClient,
private val scope: CoroutineScope,
val limits: AsrStreamingLimits = AsrStreamingLimits(),
) {
private val sessions = ConcurrentHashMap<String, Session>()
private val userGates = ConcurrentHashMap<String, UserGate>()
suspend fun createSession(
principal: GatewayPrincipal,
requestId: String,
request: CreateAsrSessionRequest,
): CreateAsrSessionResponse {
val options = request.toOptions()
.let {
val rawPcm = it.codec == "raw" && it.format in setOf("pcm", "wav")
if (!rawPcm) it.copy(estimatedDurationMillis = limits.maxDurationMillis)
else it
}
.also(::validateOptions)
val gate = userGates.compute(principal.userId) { _, existing ->
(existing ?: UserGate(Semaphore(limits.maxConcurrentSessionsPerUser))).also {
it.activeSessions.incrementAndGet()
}
}!!
if (!gate.permits.tryAcquire()) {
releaseGate(principal.userId, gate, releasePermit = false)
throw AsrConcurrencyLimitException()
}
val prepared = try {
gateway.prepare(
subject = principal,
request = AsrProviderRequest(
requestId = requestId,
options = options,
audio = ByteArray(0),
),
)
} catch (failure: Throwable) {
releaseGate(principal.userId, gate)
throw failure
}
val sessionId = UUID.randomUUID().toString()
val session = Session(
id = sessionId,
principal = principal,
requestId = requestId,
options = options,
prepared = prepared,
gate = gate,
)
sessions[sessionId] = session
session.expiry = scope.launch {
delay(limits.connectTimeoutMillis)
expire(session)
}
return CreateAsrSessionResponse(
sessionId = sessionId,
websocketPath = "/v1/gateway/asr/sessions/$sessionId/stream",
maxFrameBytes = limits.maxFrameBytes,
idleTimeoutMillis = limits.idleTimeoutMillis,
)
}
suspend fun stream(
sessionId: String,
principal: GatewayPrincipal,
audioFrames: Flow<ByteArray>,
output: ProviderOutput,
): ProviderUsage {
val session = sessions[sessionId] ?: throw AsrSessionNotFoundException()
if (session.principal.userId != principal.userId ||
session.principal.grantId != principal.grantId
) {
throw AsrSessionNotFoundException()
}
if (!session.state.compareAndSet(SessionState.READY, SessionState.STREAMING)) {
throw AsrSessionAlreadyUsedException()
}
session.expiry?.cancel()
val started = TimeSource.Monotonic.markNow()
val result = try {
withTimeout(limits.maxDurationMillis) {
upstream.transcribe(session.options, bounded(audioFrames, session.options), output)
}
} catch (failure: Throwable) {
release(session, failure)
throw failure
}
if (!result.hasResult || result.durationMillis !in 1..session.options.estimatedDurationMillis) {
val failure = VolcengineUsageException("ASR result was empty or duration exceeded reservation")
release(session, failure)
throw failure
}
val usage = ProviderUsage(
meter = UsageMeter.AUDIO_MILLISECOND,
units = result.durationMillis,
providerRequestId = result.providerRequestId,
serverDurationMillis = started.elapsedNow().inWholeMilliseconds.coerceAtLeast(1),
)
try {
gateway.settlePrepared(session.prepared, usage)
session.state.set(SessionState.SETTLED)
return usage
} finally {
// Once upstream succeeded, settlement failure must not release the
// reservation. The credit port owns idempotent retry/reconciliation.
finish(session)
}
}
private fun bounded(
source: Flow<ByteArray>,
options: AsrGatewayOptions,
): Flow<ByteArray> = flow {
var frames = 0
var bytes = 0L
val rawPcm = options.codec == "raw" && options.format in setOf("pcm", "wav")
val acceptedBytes = if (rawPcm) {
val bytesPerMillisecond = Math.multiplyExact(
options.sampleRate.toLong(),
Math.multiplyExact(options.bits.toLong(), options.channels.toLong()),
) / 8_000L
val audioBytes = Math.multiplyExact(options.estimatedDurationMillis, bytesPerMillisecond)
if (options.format == "wav") {
Math.addExact(audioBytes, WAV_HEADER_ALLOWANCE_BYTES)
} else {
audioBytes
}
} else {
limits.maxAudioBytes
}
val boundedAcceptedBytes = minOf(limits.maxAudioBytes, acceptedBytes)
try {
source.collect { frame ->
require(frame.isNotEmpty()) { "audio frame must not be empty" }
require(frame.size <= limits.maxFrameBytes) { "audio frame exceeds the limit" }
frames = Math.addExact(frames, 1)
require(frames <= limits.maxFrames) { "audio frame count exceeds the limit" }
bytes = Math.addExact(bytes, frame.size.toLong())
require(bytes <= boundedAcceptedBytes) { "audio stream exceeds the declared duration" }
emit(frame)
}
} catch (failure: CancellationException) {
throw failure
}
}
private suspend fun expire(session: Session) {
if (session.state.compareAndSet(SessionState.READY, SessionState.RELEASED)) {
runCatching { gateway.releasePrepared(session.prepared, AsrSessionExpiredException()) }
finish(session)
}
}
private suspend fun release(session: Session, failure: Throwable) {
if (session.state.getAndSet(SessionState.RELEASED) != SessionState.RELEASED) {
withContext(NonCancellable) {
runCatching { gateway.releasePrepared(session.prepared, failure) }
.onFailure(failure::addSuppressed)
}
}
finish(session)
}
private fun finish(session: Session) {
if (sessions.remove(session.id, session)) {
releaseGate(session.principal.userId, session.gate)
}
session.expiry?.cancel()
}
private fun releaseGate(
userId: String,
gate: UserGate,
releasePermit: Boolean = true,
) {
if (releasePermit) gate.permits.release()
userGates.compute(userId) { _, current ->
if (current !== gate) {
current
} else if (gate.activeSessions.decrementAndGet() == 0) {
null
} else {
gate
}
}
}
private fun validateOptions(options: AsrGatewayOptions) {
require(options.estimatedDurationMillis in 1..limits.maxDurationMillis) {
"estimatedDurationMillis is out of range"
}
require(options.format in setOf("pcm", "wav", "ogg", "mp3")) { "unsupported audio format" }
require(options.codec in setOf("raw", "opus")) { "unsupported audio codec" }
require(options.sampleRate == 16_000) { "only 16000 Hz audio is supported" }
require(options.bits == 16) { "only 16-bit audio is supported" }
require(options.channels in 1..2) { "channels must be 1 or 2" }
require(options.language == null || options.language.length <= 32) { "language is too long" }
require(options.format != "ogg" || options.codec == "opus") { "ogg audio requires opus codec" }
}
private fun CreateAsrSessionRequest.toOptions() = AsrGatewayOptions(
format = format,
codec = codec,
sampleRate = sampleRate,
bits = bits,
channels = channels,
language = language,
estimatedDurationMillis = estimatedDurationMillis,
)
private data class Session(
val id: String,
val principal: GatewayPrincipal,
val requestId: String,
val options: AsrGatewayOptions,
val prepared: PreparedGatewayRequest,
val gate: UserGate,
val state: AtomicReference<SessionState> = AtomicReference(SessionState.READY),
var expiry: Job? = null,
)
private data class UserGate(
val permits: Semaphore,
val activeSessions: AtomicInteger = AtomicInteger(),
)
private enum class SessionState {
READY,
STREAMING,
SETTLED,
RELEASED,
}
private companion object {
const val WAV_HEADER_ALLOWANCE_BYTES = 44L
}
}
class AsrConcurrencyLimitException : RuntimeException("Too many concurrent ASR sessions")
class AsrSessionNotFoundException : RuntimeException("ASR session was not found")
class AsrSessionAlreadyUsedException : RuntimeException("ASR session was already used")
class AsrSessionExpiredException : RuntimeException("ASR session expired before connection")
@@ -0,0 +1,243 @@
package com.osglab.account.features.gateway.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Serializable
enum class GatewayCapability {
@SerialName("polish")
POLISH,
@SerialName("ai")
AI,
@SerialName("agent")
AGENT,
@SerialName("asr")
ASR,
}
@Serializable
enum class UsageMeter {
@SerialName("llm_token")
LLM_TOKEN,
@SerialName("audio_millisecond")
AUDIO_MILLISECOND,
}
/**
* Authenticated identity supplied by the host application. The gateway never
* parses or verifies JWTs itself.
*/
data class GatewayPrincipal(
val userId: String,
val grantId: String? = null,
// Callers must grant capabilities explicitly. An identity with omitted
// scopes is intentionally unable to invoke a managed provider.
val scopes: Set<GatewayCapability> = emptySet(),
) {
// Kept as a compatibility name for the existing account-scoped persistence.
val accountId: String
get() = userId
}
typealias GatewaySubject = GatewayPrincipal
@Serializable
data class TextGatewayRequest(
val input: String,
val context: String? = null,
val maxOutputTokens: Int = 512,
val temperature: Double = 0.2,
val stream: Boolean = false,
)
@Serializable
data class AsrGatewayOptions(
val format: String = "pcm",
val codec: String = "raw",
val sampleRate: Int = 16_000,
val bits: Int = 16,
val channels: Int = 1,
val language: String? = null,
val estimatedDurationMillis: Long,
)
sealed interface ProviderRequest {
val requestId: String
val capability: GatewayCapability
}
data class TextProviderRequest(
override val requestId: String,
override val capability: GatewayCapability,
val input: String,
val context: String?,
val maxOutputTokens: Int,
val temperature: Double,
val stream: Boolean,
) : ProviderRequest
data class AsrProviderRequest(
override val requestId: String,
val options: AsrGatewayOptions,
val audio: ByteArray,
) : ProviderRequest {
override val capability: GatewayCapability = GatewayCapability.ASR
}
data class ProviderUsage(
val meter: UsageMeter,
val units: Long,
val providerRequestId: String? = null,
val inputUnits: Long? = null,
val outputUnits: Long? = null,
val serverDurationMillis: Long = 0,
)
fun interface ProviderOutput {
suspend fun emit(bytes: ByteArray)
}
object GatewayLimits {
const val MAX_JSON_BODY_BYTES = 300 * 1024
const val MAX_AUDIO_BYTES = 20 * 1024 * 1024
const val MAX_AUDIO_FRAME_BYTES = 64 * 1024
const val MAX_AUDIO_FRAMES = 10_000
const val MAX_UPSTREAM_RESPONSE_BYTES = 8 * 1024 * 1024
const val MAX_SSE_EVENTS = 8_192
const val MAX_SSE_LINE_BYTES = 128 * 1024
const val MAX_AUDIO_MILLIS = 10 * 60 * 1_000L
const val MAX_TEXT_INPUT_CHARS = 32_000
const val MAX_TEXT_CONTEXT_CHARS = 32_000
const val MAX_OUTPUT_TOKENS = 4_096
}
object TextRequestPolicy {
fun validate(
request: TextGatewayRequest,
capability: GatewayCapability? = null,
) {
require(request.input.isNotBlank()) { "input must not be blank" }
require(request.input.length <= GatewayLimits.MAX_TEXT_INPUT_CHARS) { "input is too long" }
require((request.context?.length ?: 0) <= GatewayLimits.MAX_TEXT_CONTEXT_CHARS) {
"context is too long"
}
require(request.maxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS) {
"maxOutputTokens is out of range"
}
require(request.temperature in 0.0..1.0 && request.temperature.isFinite()) {
"temperature is out of range"
}
require(capability != GatewayCapability.AGENT || !request.stream) {
"agent requests must be non-streaming so the structured result can be validated"
}
}
}
object AudioDurationPolicy {
fun reservationMillis(
audioBytes: Int,
options: AsrGatewayOptions,
): Long {
require(audioBytes in 1..GatewayLimits.MAX_AUDIO_BYTES) {
"audio exceeds the gateway limit"
}
require(options.estimatedDurationMillis in 1..GatewayLimits.MAX_AUDIO_MILLIS) {
"estimatedDurationMillis is out of range"
}
val rawPcm = options.codec == "raw" &&
(options.format == "pcm" || options.format == "wav")
if (rawPcm) {
val payloadBytes = if (options.format == "wav") {
(audioBytes - WAV_HEADER_ALLOWANCE_BYTES).coerceAtLeast(1)
} else {
audioBytes
}
val bitsPerSecond = Math.multiplyExact(
Math.multiplyExact(options.sampleRate.toLong(), options.bits.toLong()),
options.channels.toLong(),
)
val computed = ceilDivide(
Math.multiplyExact(payloadBytes.toLong(), 8_000L),
bitsPerSecond,
)
val tolerance = maxOf(100L, computed / 10L)
require(options.estimatedDurationMillis + tolerance >= computed) {
"declared audio duration is shorter than the PCM payload"
}
return maxOf(computed, options.estimatedDurationMillis)
.coerceAtMost(GatewayLimits.MAX_AUDIO_MILLIS)
}
// Compressed duration cannot be proven from bytes alone. Reject an
// impossible low declaration and reserve the full accepted duration.
val minimumFromBytes = ceilDivide(
Math.multiplyExact(audioBytes.toLong(), 8_000L),
MAX_COMPRESSED_BITS_PER_SECOND,
)
require(options.estimatedDurationMillis >= minimumFromBytes) {
"declared audio duration is implausible for the compressed payload"
}
return GatewayLimits.MAX_AUDIO_MILLIS
}
private fun ceilDivide(numerator: Long, denominator: Long): Long =
Math.addExact(numerator, denominator - 1L) / denominator
private const val WAV_HEADER_ALLOWANCE_BYTES = 44
private const val MAX_COMPRESSED_BITS_PER_SECOND = 512_000L
}
@Serializable
data class ProviderDescriptor(
val id: String,
val capabilities: Set<GatewayCapability>,
val streaming: Boolean,
val usageMeter: UsageMeter,
)
@Serializable
data class GatewayCatalogResponse(
val providers: List<ProviderDescriptor>,
)
@Serializable
data class GatewayErrorResponse(
val code: String,
val message: String,
val requestId: String,
)
@Serializable
data class CreateGatewayGrantRequest(
val scopes: Set<GatewayCapability>,
val lifetimeSeconds: Long? = null,
)
@Serializable
data class RefreshGatewayGrantRequest(
val refreshToken: String,
)
@Serializable
data class GatewayGrantTokens(
val grantId: String,
val scopes: Set<GatewayCapability>,
val accessToken: String,
val accessExpiresAt: String,
val refreshToken: String,
val refreshExpiresAt: String,
)
data class GatewayGrant(
val id: String,
val accountId: String,
val scopes: Set<GatewayCapability>,
val expiresAt: Instant,
val revokedAt: Instant? = null,
)
@@ -0,0 +1,8 @@
package com.osglab.account.features.gateway.polish
typealias DeepSeekClient =
com.osglab.account.features.gateway.providers.deepseek.DeepSeekClient
typealias KtorDeepSeekClient =
com.osglab.account.features.gateway.providers.deepseek.KtorDeepSeekClient
typealias DeepSeekGatewayProvider =
com.osglab.account.features.gateway.providers.deepseek.DeepSeekProvider
@@ -0,0 +1,200 @@
package com.osglab.account.features.gateway.ports
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayGrant
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import io.ktor.server.application.ApplicationCall
import java.time.Instant
data class CreditReservation(
val id: String,
val reservedUnits: Long,
)
data class ProviderUsageEstimate(
val meter: UsageMeter,
val units: Long,
val inputUnits: Long? = null,
val outputUnits: Long? = null,
)
/**
* The account/credit feature implements this port. Implementations must make
* reserve and settle idempotent for the supplied request ID.
*/
interface CreditReservationPort {
suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
): CreditReservation
suspend fun reserve(
accountId: String,
estimate: ProviderUsageEstimate,
requestId: String,
): CreditReservation =
reserve(accountId, estimate.meter, estimate.units, requestId)
suspend fun settle(reservationId: String, actualUnits: Long)
suspend fun settle(reservationId: String, usage: ProviderUsage) {
settle(reservationId, usage.units)
}
suspend fun release(reservationId: String)
/**
* Reverses an already settled reservation. Implementations must make this
* operation idempotent for the reservation.
*/
suspend fun refund(reservationId: String) {
throw UnsupportedOperationException("Billing refund is not configured")
}
}
typealias BillingPort = CreditReservationPort
typealias CreditMeterPort = CreditReservationPort
typealias CreditMeter = CreditReservationPort
/**
* Authentication remains outside the gateway. Return null when the call has
* no valid application identity.
*/
interface GatewayPrincipalResolver {
suspend fun resolve(call: ApplicationCall): GatewayPrincipal?
}
fun interface GatewayPrincipalPort : GatewayPrincipalResolver
typealias GatewayIdentityPort = GatewayPrincipalPort
fun interface GatewayAccessTokenPort : GatewayPrincipalResolver
/**
* Grant lookup remains outside the routes so gateway_grants can be backed by
* the account database without coupling this feature to its schema library.
*/
fun interface GatewayGrantPort {
suspend fun isAllowed(accountId: String, capability: GatewayCapability): Boolean
}
data class NewGatewayGrant(
val id: String,
val accountId: String,
val idempotencyKey: String,
val scopes: Set<GatewayCapability>,
val expiresAt: Instant,
val refreshTokenId: String,
val refreshFamilyId: String,
val refreshTokenHash: String,
val refreshExpiresAt: Instant,
)
data class StoredGatewayRefresh(
val grant: GatewayGrant,
val tokenId: String,
val familyId: String,
val expiresAt: Instant,
)
sealed interface GatewayRefreshRotationResult {
data class Rotated(val refresh: StoredGatewayRefresh) : GatewayRefreshRotationResult
data object Invalid : GatewayRefreshRotationResult
data object ReuseDetected : GatewayRefreshRotationResult
}
/**
* Security-sensitive grant and rotating-refresh state. Implementations must
* lock the current refresh row while rotating it.
*/
interface GatewayGrantRepository : GatewayGrantPort {
suspend fun create(grant: NewGatewayGrant, now: Instant): StoredGatewayRefresh
suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): GatewayRefreshRotationResult
suspend fun revoke(accountId: String, grantId: String, now: Instant): Boolean
suspend fun findActive(
grantId: String,
accountId: String,
scopes: Set<GatewayCapability>,
now: Instant,
): GatewayGrant?
}
data class ProviderRequestMetadata(
val requestId: String,
val accountId: String,
val reservationId: String,
val providerId: String,
val capability: GatewayCapability,
)
data class ProviderRefund(
val requestId: String,
val accountId: String,
val reservationId: String,
)
enum class ProviderRequestState {
CLAIMED,
STARTED,
SETTLEMENT_PENDING,
SETTLED,
RELEASED,
MANUAL_REVIEW,
}
data class PendingSettlement(
val requestId: String,
val accountId: String,
val reservationId: String,
val usage: ProviderUsage,
)
class GatewayRequestAlreadyClaimedException(
val state: ProviderRequestState,
) : RuntimeException("Gateway request is already ${state.name.lowercase()}")
/**
* Persists metadata and usage only. Request prompts, audio and provider
* response bodies must never be passed to this port.
*/
interface GatewayUsagePort {
/**
* Atomically claims an account-scoped request ID. Any existing state is a
* terminal replay from the gateway's perspective and must not call upstream.
*/
suspend fun claim(metadata: ProviderRequestMetadata)
suspend fun markStarted(accountId: String, requestId: String)
suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
)
suspend fun markSucceeded(accountId: String, requestId: String, usage: ProviderUsage)
suspend fun markReleased(accountId: String, requestId: String, errorCode: String)
suspend fun markManualReview(accountId: String, requestId: String, errorCode: String)
suspend fun findSettlementPending(limit: Int): List<PendingSettlement>
suspend fun markRefunded(accountId: String, requestId: String) = Unit
suspend fun findRefundPending(limit: Int): List<ProviderRefund> = emptyList()
}
@@ -0,0 +1,43 @@
package com.osglab.account.features.gateway.providers
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
interface GatewayProvider {
val descriptor: ProviderDescriptor
fun accepts(request: ProviderRequest): Boolean =
request.capability in descriptor.capabilities
suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage
}
class ProviderCatalog(
providers: Collection<GatewayProvider>,
) {
private val providers = providers.toList()
init {
require(this.providers.map { it.descriptor.id }.distinct().size == this.providers.size) {
"Gateway provider IDs must be unique"
}
}
fun descriptors(): List<ProviderDescriptor> =
providers.map(GatewayProvider::descriptor).sortedBy(ProviderDescriptor::id)
fun providerFor(request: ProviderRequest): GatewayProvider =
providers.firstOrNull { it.accepts(request) }
?: throw UnsupportedGatewayCapabilityException(request.capability.name.lowercase())
}
class UnsupportedGatewayCapabilityException(capability: String) :
IllegalArgumentException("No gateway provider is configured for capability '$capability'")
/**
* Upstream returned an invalid metering/result envelope. Gateway orchestration
* treats this as provider failure and releases the reservation.
*/
open class ProviderCompletionException(message: String) : RuntimeException(message)
@@ -0,0 +1,448 @@
package com.osglab.account.features.gateway.providers.deepseek
import com.osglab.account.features.gateway.agent.AgentPlan
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCompletionException
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.header
import io.ktor.client.request.preparePost
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.Url
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.utils.io.readLineStrict
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readRemaining
import kotlinx.io.readByteArray
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class DeepSeekConfig(
val endpoint: String,
val apiKey: String,
val model: String,
) {
init {
val url = runCatching { Url(endpoint) }
.getOrElse { throw DeepSeekConfigurationException("DEEPSEEK_ENDPOINT is invalid") }
if (url.protocol.name != "https") {
throw DeepSeekConfigurationException("DEEPSEEK_ENDPOINT must use HTTPS")
}
if (apiKey.isBlank()) {
throw DeepSeekConfigurationException("DEEPSEEK_API_KEY must not be blank")
}
if (model.isBlank()) {
throw DeepSeekConfigurationException("DEEPSEEK_MODEL must not be blank")
}
}
}
fun interface DeepSeekClient {
suspend fun complete(request: TextProviderRequest, output: ProviderOutput): ProviderUsage
}
class DeepSeekProvider(
private val upstream: DeepSeekClient,
) : GatewayProvider {
constructor(
client: HttpClient,
config: DeepSeekConfig,
json: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
},
) : this(KtorDeepSeekClient(client, config, json))
override val descriptor = ProviderDescriptor(
id = "deepseek",
capabilities = setOf(
GatewayCapability.POLISH,
GatewayCapability.AI,
GatewayCapability.AGENT,
),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
override fun accepts(request: ProviderRequest): Boolean =
request is TextProviderRequest && super.accepts(request)
override suspend fun execute(
request: ProviderRequest,
output: ProviderOutput,
): ProviderUsage {
require(request is TextProviderRequest) { "DeepSeek only accepts text requests" }
validate(request)
return upstream.complete(request, output)
}
private fun validate(request: TextProviderRequest) {
require(request.input.isNotBlank()) { "input must not be blank" }
require(request.input.length <= GatewayLimits.MAX_TEXT_INPUT_CHARS) { "input is too long" }
require((request.context?.length ?: 0) <= GatewayLimits.MAX_TEXT_CONTEXT_CHARS) {
"context is too long"
}
require(request.maxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS) {
"maxOutputTokens is out of range"
}
require(request.temperature in 0.0..1.0 && request.temperature.isFinite()) {
"temperature is out of range"
}
require(request.capability != GatewayCapability.AGENT || !request.stream) {
"agent requests must be non-streaming"
}
}
}
/**
* Production DeepSeek client. Endpoint and model exist only in server-side
* configuration and cannot be overridden by a gateway request.
*/
class KtorDeepSeekClient(
private val client: HttpClient,
private val config: DeepSeekConfig,
private val json: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
},
) : DeepSeekClient {
override suspend fun complete(
request: TextProviderRequest,
output: ProviderOutput,
): ProviderUsage {
val payload = DeepSeekChatRequest(
model = config.model,
messages = controlledMessages(request),
maxTokens = request.maxOutputTokens,
temperature = request.temperature,
stream = request.stream,
streamOptions = if (request.stream) StreamOptions(includeUsage = true) else null,
responseFormat = if (request.capability == GatewayCapability.AGENT) {
ResponseFormat(type = "json_object")
} else {
null
},
)
return client.preparePost("${config.endpoint.trimEnd('/')}/chat/completions") {
bearerAuth(config.apiKey)
contentType(ContentType.Application.Json)
header(HttpHeaders.Accept, if (request.stream) ContentType.Text.EventStream else ContentType.Application.Json)
header("X-Request-ID", request.requestId)
setBody(payload)
}.execute { response ->
if (!response.status.isSuccess()) {
// Consume but never log or persist a provider body.
runCatching { response.body<ByteReadChannel>().readBounded() }
throw DeepSeekProviderException("DeepSeek returned HTTP ${response.status.value}")
}
val expectedContentType = if (request.stream) {
ContentType.Text.EventStream
} else {
ContentType.Application.Json
}
val responseContentType = response.headers[HttpHeaders.ContentType]
?.let { runCatching { ContentType.parse(it) }.getOrNull() }
if (responseContentType?.match(expectedContentType) != true) {
// Consume the bounded body without exposing it to logs or callers.
runCatching { response.body<ByteReadChannel>().readBounded() }
throw DeepSeekProviderException("DeepSeek returned an unexpected content type")
}
if (request.stream) {
forwardSse(response.body(), request, output)
} else {
forwardJson(response.body<ByteReadChannel>().readBounded(), request, output)
}
}
}
private suspend fun forwardJson(
bytes: ByteArray,
request: TextProviderRequest,
output: ProviderOutput,
): ProviderUsage {
val payload = bytes.decodeToString()
val content = extractAssistantContent(payload)
?: throw DeepSeekEmptyResultException()
if (content.isBlank()) throw DeepSeekEmptyResultException()
if (request.capability == GatewayCapability.AGENT) validateAgentContent(content)
val usage = extractUsage(payload)?.toProviderUsageOrNull()
?: throw DeepSeekProviderException("DeepSeek response omitted token usage")
output.emit(bytes)
return usage
}
private suspend fun forwardSse(
channel: io.ktor.utils.io.ByteReadChannel,
request: TextProviderRequest,
output: ProviderOutput,
): ProviderUsage {
var usage: DeepSeekUsage? = null
var providerUsage: ProviderUsage? = null
var emittedBytes = 0L
var eventCount = 0
var contentBytes = 0L
var terminalChoiceSeen = false
var doneSeen = false
val assistantContent = StringBuilder()
while (true) {
val line = channel.readLineStrict(
limit = GatewayLimits.MAX_SSE_LINE_BYTES.toLong(),
) ?: break
val encoded = "$line\n".encodeToByteArray()
emittedBytes = Math.addExact(emittedBytes, encoded.size.toLong())
if (emittedBytes > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
throw DeepSeekProviderException("DeepSeek stream exceeded the response limit")
}
if (line.startsWith("data:")) {
eventCount += 1
if (eventCount > GatewayLimits.MAX_SSE_EVENTS) {
throw DeepSeekProviderException("DeepSeek stream exceeded the event limit")
}
val data = line.removePrefix("data:").trim()
if (data == "[DONE]") {
if (!terminalChoiceSeen) {
throw DeepSeekProviderException("DeepSeek stream ended without a terminal choice")
}
providerUsage = usage?.toProviderUsageOrNull()
?: throw DeepSeekProviderException("DeepSeek stream omitted token usage")
doneSeen = true
output.emit(encoded)
break
}
val event = runCatching { json.parseToJsonElement(data).jsonObject }
.getOrElse { throw DeepSeekProviderException("DeepSeek returned malformed SSE data") }
if (event["error"] != null && event["error"] !is JsonNull) {
throw DeepSeekProviderException("DeepSeek returned an error event")
}
val eventUsage = extractUsage(data)
eventUsage?.let { usage = it }
val choices = runCatching {
event["choices"]?.jsonArray
?: throw IllegalArgumentException("choices is missing")
}.getOrElse {
throw DeepSeekProviderException("DeepSeek SSE data omitted valid choices")
}
val choice = runCatching { choices.firstOrNull()?.jsonObject }
.getOrElse {
throw DeepSeekProviderException("DeepSeek returned an invalid choice")
}
if (choice == null && eventUsage == null) {
throw DeepSeekProviderException("DeepSeek returned an empty non-usage event")
}
val finishReason = choice?.get("finish_reason")
if (finishReason != null && finishReason !is JsonNull) {
if (!finishReason.jsonPrimitive.isString ||
finishReason.jsonPrimitive.content.isBlank()
) {
throw DeepSeekProviderException("DeepSeek returned an invalid finish reason")
}
terminalChoiceSeen = true
}
extractStreamContent(data)?.let { chunk ->
contentBytes = Math.addExact(contentBytes, chunk.encodeToByteArray().size.toLong())
if (contentBytes > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
throw DeepSeekProviderException("DeepSeek content exceeded the response limit")
}
assistantContent.append(chunk)
}
}
output.emit(encoded)
}
if (!doneSeen) throw DeepSeekProviderException("DeepSeek stream closed before [DONE]")
if (assistantContent.isBlank()) throw DeepSeekEmptyResultException()
if (request.capability == GatewayCapability.AGENT) {
validateAgentContent(assistantContent.toString())
}
return requireNotNull(providerUsage)
}
private fun extractUsage(payload: String): DeepSeekUsage? =
runCatching {
val usage = json.parseToJsonElement(payload).jsonObject["usage"]?.jsonObject ?: return null
val input = usage["prompt_tokens"]?.jsonPrimitive?.content?.toLong()
val output = usage["completion_tokens"]?.jsonPrimitive?.content?.toLong()
val total = usage["total_tokens"]?.jsonPrimitive?.content?.toLong()
?: if (input != null && output != null) Math.addExact(input, output) else return null
DeepSeekUsage(total, input, output)
}.getOrNull()
private fun DeepSeekUsage.toProviderUsageOrNull(): ProviderUsage? {
val inputTokens = input ?: return null
val outputTokens = output ?: return null
if (inputTokens < 0 || outputTokens < 0 || total < 0 ||
Math.addExact(inputTokens, outputTokens) != total
) {
throw DeepSeekUsageException("DeepSeek returned inconsistent token usage")
}
return ProviderUsage(
meter = UsageMeter.LLM_TOKEN,
units = total,
inputUnits = inputTokens,
outputUnits = outputTokens,
)
}
private fun validateAgentContent(content: String) {
val plan = runCatching {
STRICT_AGENT_JSON.decodeFromString<AgentPlan>(content)
}.getOrElse {
throw DeepSeekProviderException("Agent response did not match the required schema")
}
if (plan.summary.isBlank() ||
plan.summary.length > MAX_AGENT_FIELD_CHARS ||
plan.steps.isEmpty() ||
plan.steps.size > MAX_AGENT_STEPS ||
plan.steps.any {
it.id.isBlank() ||
it.title.isBlank() ||
it.description.isBlank() ||
it.id.length > MAX_AGENT_ID_CHARS ||
it.title.length > MAX_AGENT_FIELD_CHARS ||
it.description.length > MAX_AGENT_FIELD_CHARS
} ||
plan.warnings.size > MAX_AGENT_WARNINGS ||
plan.warnings.any { it.length > MAX_AGENT_FIELD_CHARS }
) {
throw DeepSeekProviderException("Agent response exceeded the structured-result policy")
}
}
private fun extractAssistantContent(payload: String): String? = runCatching {
json.parseToJsonElement(payload)
.jsonObject["choices"]
?.let { choices -> choices.jsonArray.firstOrNull() }
?.jsonObject
?.get("message")
?.jsonObject
?.get("content")
?.jsonPrimitive
?.takeIf { it.isString }
?.content
}.getOrNull()
private fun extractStreamContent(payload: String): String? = runCatching {
json.parseToJsonElement(payload)
.jsonObject["choices"]
?.let { choices -> choices.jsonArray.firstOrNull() }
?.jsonObject
?.get("delta")
?.jsonObject
?.get("content")
?.jsonPrimitive
?.takeIf { it.isString }
?.content
}.getOrNull()
private suspend fun ByteReadChannel.readBounded(): ByteArray {
val bytes = readRemaining(GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES.toLong() + 1L)
.readByteArray()
if (bytes.size > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
throw DeepSeekProviderException("DeepSeek response exceeded the gateway limit")
}
return bytes
}
private fun controlledMessages(request: TextProviderRequest): List<ChatMessage> {
val system = when (request.capability) {
GatewayCapability.POLISH ->
"Polish the user's text while preserving meaning. Return only the polished text."
GatewayCapability.AI ->
"Answer the user's question accurately and concisely. Do not claim actions you did not perform."
GatewayCapability.AGENT ->
"""
Return only JSON with this schema:
{"summary":"string","steps":[{"id":"string","title":"string","description":"string"}],"warnings":["string"]}.
Produce a declarative plan only. Never execute actions, invoke tools, include commands or URLs,
or claim that any client-side or external side effect occurred.
""".trimIndent()
GatewayCapability.ASR -> error("ASR is not a DeepSeek capability")
}
val userText = buildString {
request.context?.takeIf(String::isNotBlank)?.let {
append("Context:\n")
append(it)
append("\n\n")
}
append(request.input)
}
return listOf(ChatMessage("system", system), ChatMessage("user", userText))
}
private companion object {
const val MAX_AGENT_ID_CHARS = 128
const val MAX_AGENT_FIELD_CHARS = 4_096
const val MAX_AGENT_STEPS = 64
const val MAX_AGENT_WARNINGS = 64
val STRICT_AGENT_JSON = Json {
ignoreUnknownKeys = false
explicitNulls = false
}
}
}
@Serializable
private data class DeepSeekChatRequest(
val model: String,
val messages: List<ChatMessage>,
@SerialName("max_tokens")
val maxTokens: Int,
val temperature: Double,
val stream: Boolean,
@SerialName("stream_options")
val streamOptions: StreamOptions?,
@SerialName("response_format")
val responseFormat: ResponseFormat?,
)
@Serializable
private data class ChatMessage(
val role: String,
val content: String,
)
@Serializable
private data class StreamOptions(
@SerialName("include_usage")
val includeUsage: Boolean,
)
@Serializable
private data class ResponseFormat(
val type: String,
)
class DeepSeekConfigurationException(message: String) : IllegalStateException(message)
class DeepSeekProviderException(message: String) : RuntimeException(message)
class DeepSeekUsageException(message: String) : ProviderCompletionException(message)
class DeepSeekEmptyResultException : RuntimeException("DeepSeek returned an empty result")
private data class DeepSeekUsage(
val total: Long,
val input: Long?,
val output: Long?,
)
@@ -0,0 +1,248 @@
package com.osglab.account.features.gateway.providers.volcengine
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
enum class SaucMessageType(val code: Int) {
FULL_CLIENT_REQUEST(0x1),
AUDIO_ONLY_REQUEST(0x2),
FULL_SERVER_RESPONSE(0x9),
ERROR_RESPONSE(0xF),
;
companion object {
fun from(code: Int): SaucMessageType =
entries.firstOrNull { it.code == code }
?: throw SaucProtocolException("Unsupported SAUC message type: $code")
}
}
enum class SaucSerialization(val code: Int) {
RAW(0),
JSON(1),
}
enum class SaucCompression(val code: Int) {
NONE(0),
GZIP(1),
}
data class SaucFrame(
val type: SaucMessageType,
val flags: Int,
val serialization: SaucSerialization,
val compression: SaucCompression,
val payload: ByteArray,
val sequence: Int? = null,
val errorCode: Int? = null,
) {
val isLast: Boolean
get() = flags == FLAG_LAST_WITHOUT_SEQUENCE || flags == FLAG_LAST_WITH_SEQUENCE
companion object {
const val FLAG_LAST_WITHOUT_SEQUENCE = 0x2
const val FLAG_LAST_WITH_SEQUENCE = 0x3
}
}
/**
* Codec for the binary framing documented for the SAUC v3 API. The API name is
* v3, while the binary header's protocol-version nibble is currently v1.
*/
class SaucV3Codec(
private val maxPayloadBytes: Int = 4 * 1024 * 1024,
) {
fun fullClientRequest(jsonPayload: ByteArray): ByteArray =
encode(
type = SaucMessageType.FULL_CLIENT_REQUEST,
flags = 0,
serialization = SaucSerialization.JSON,
compression = SaucCompression.GZIP,
payload = gzip(jsonPayload),
)
fun audioRequest(audio: ByteArray, isLast: Boolean): ByteArray =
encode(
type = SaucMessageType.AUDIO_ONLY_REQUEST,
flags = if (isLast) SaucFrame.FLAG_LAST_WITHOUT_SEQUENCE else 0,
serialization = SaucSerialization.RAW,
compression = SaucCompression.GZIP,
payload = gzip(audio),
)
fun decodeServerFrame(bytes: ByteArray): SaucFrame {
if (bytes.size < MIN_FRAME_BYTES) {
throw SaucProtocolException("SAUC frame is shorter than 8 bytes")
}
val protocolVersion = bytes[0].toInt().ushr(4) and 0x0F
val headerWords = bytes[0].toInt() and 0x0F
if (protocolVersion != PROTOCOL_VERSION) {
throw SaucProtocolException("Unsupported SAUC protocol version: $protocolVersion")
}
if (headerWords < 1) {
throw SaucProtocolException("Invalid SAUC header size")
}
val headerBytes = headerWords * 4
if (headerBytes > bytes.size - 4) {
throw SaucProtocolException("SAUC header exceeds frame bounds")
}
val type = SaucMessageType.from(bytes[1].toInt().ushr(4) and 0x0F)
if (type != SaucMessageType.FULL_SERVER_RESPONSE && type != SaucMessageType.ERROR_RESPONSE) {
throw SaucProtocolException("Unexpected server message type: $type")
}
val flags = bytes[1].toInt() and 0x0F
if (type == SaucMessageType.FULL_SERVER_RESPONSE &&
flags != FLAG_SEQUENCE &&
flags != SaucFrame.FLAG_LAST_WITH_SEQUENCE
) {
throw SaucProtocolException("SAUC server response must include a sequence number")
}
if (type == SaucMessageType.ERROR_RESPONSE && flags != 0) {
throw SaucProtocolException("SAUC error response used unsupported flags")
}
val serializationCode = bytes[2].toInt().ushr(4) and 0x0F
val serialization = SaucSerialization.entries.firstOrNull { it.code == serializationCode }
?: throw SaucProtocolException(
"Unsupported SAUC serialization value: $serializationCode",
)
val compressionCode = bytes[2].toInt() and 0x0F
val compression = SaucCompression.entries.firstOrNull { it.code == compressionCode }
?: throw SaucProtocolException(
"Unsupported SAUC compression value: $compressionCode",
)
if (serialization != SaucSerialization.JSON) {
throw SaucProtocolException("SAUC server response must use JSON serialization")
}
if (bytes[3].toInt() != 0) {
throw SaucProtocolException("SAUC reserved header byte must be zero")
}
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN)
buffer.position(headerBytes)
val sequence = when {
type == SaucMessageType.FULL_SERVER_RESPONSE &&
(flags == FLAG_SEQUENCE || flags == SaucFrame.FLAG_LAST_WITH_SEQUENCE) ->
requireInt(buffer, "sequence")
else -> null
}
val errorCode = if (type == SaucMessageType.ERROR_RESPONSE) {
requireInt(buffer, "error code")
} else {
null
}
val payloadSize = requireInt(buffer, "payload size")
if (payloadSize < 0 || payloadSize > maxPayloadBytes) {
throw SaucProtocolException("SAUC payload size is outside the allowed range")
}
if (buffer.remaining() != payloadSize) {
throw SaucProtocolException(
"SAUC payload size mismatch: declared $payloadSize, received ${buffer.remaining()}",
)
}
val encodedPayload = ByteArray(payloadSize).also(buffer::get)
val payload = when (compression) {
SaucCompression.NONE -> encodedPayload
SaucCompression.GZIP -> gunzip(encodedPayload)
}
return SaucFrame(
type = type,
flags = flags,
serialization = serialization,
compression = compression,
payload = payload,
sequence = sequence,
errorCode = errorCode,
)
}
private fun encode(
type: SaucMessageType,
flags: Int,
serialization: SaucSerialization,
compression: SaucCompression,
payload: ByteArray,
): ByteArray {
require(payload.size <= maxPayloadBytes) { "SAUC payload exceeds configured maximum" }
val buffer = ByteBuffer.allocate(MIN_FRAME_BYTES + payload.size).order(ByteOrder.BIG_ENDIAN)
buffer.put(((PROTOCOL_VERSION shl 4) or HEADER_WORDS).toByte())
buffer.put(((type.code shl 4) or flags).toByte())
buffer.put(((serialization.code shl 4) or compression.code).toByte())
buffer.put(0.toByte())
buffer.putInt(payload.size)
buffer.put(payload)
return buffer.array()
}
private fun requireInt(buffer: ByteBuffer, field: String): Int {
if (buffer.remaining() < Int.SIZE_BYTES) {
throw SaucProtocolException("SAUC frame is missing $field")
}
return buffer.int
}
private fun gzip(bytes: ByteArray): ByteArray =
ByteArrayOutputStream().use { output ->
GZIPOutputStream(output).use { it.write(bytes) }
output.toByteArray()
}
private fun gunzip(bytes: ByteArray): ByteArray =
runCatching {
GZIPInputStream(ByteArrayInputStream(bytes)).use { input ->
val decoded = input.readNBytes(maxPayloadBytes + 1)
if (decoded.size > maxPayloadBytes) {
throw SaucProtocolException("Decompressed SAUC payload exceeds the allowed range")
}
decoded
}
}.getOrElse {
if (it is SaucProtocolException) throw it
throw SaucProtocolException("Invalid gzip payload", it)
}
private companion object {
const val PROTOCOL_VERSION = 1
const val HEADER_WORDS = 1
const val MIN_FRAME_BYTES = 8
const val FLAG_SEQUENCE = 0x1
}
}
class SaucSequenceValidator {
private var lastSequence = 0
private var finalSeen = false
fun accept(frame: SaucFrame) {
if (frame.type == SaucMessageType.ERROR_RESPONSE) return
if (finalSeen) throw SaucProtocolException("SAUC frame arrived after the final frame")
val sequence = frame.sequence
?: throw SaucProtocolException("SAUC server frame omitted its sequence")
val expected = Math.addExact(lastSequence, 1)
if (frame.isLast) {
if (sequence >= 0 || Math.abs(sequence.toLong()) != expected.toLong()) {
throw SaucProtocolException("SAUC final sequence is invalid")
}
finalSeen = true
} else {
if (sequence != expected) {
throw SaucProtocolException("SAUC sequence is not strictly increasing")
}
lastSequence = sequence
}
}
}
class SaucProtocolException(message: String, cause: Throwable? = null) :
RuntimeException(message, cause)
@@ -0,0 +1,400 @@
package com.osglab.account.features.gateway.providers.volcengine
import com.osglab.account.features.gateway.models.AsrGatewayOptions
import com.osglab.account.features.gateway.models.AsrProviderRequest
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCompletionException
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.http.Url
import io.ktor.websocket.Frame
import io.ktor.websocket.readBytes
import io.ktor.websocket.send
import java.util.UUID
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
data class VolcengineAsrConfig(
val endpoint: String,
val resourceId: String,
val appId: String? = null,
val accessToken: String? = null,
val apiKey: String? = null,
val responseTimeoutMillis: Long = 360_000,
) {
init {
val url = runCatching { Url(endpoint) }
.getOrElse { throw VolcengineConfigurationException("VOLCENGINE_ASR_ENDPOINT is invalid") }
if (url.protocol.name != "wss") {
throw VolcengineConfigurationException("VOLCENGINE_ASR_ENDPOINT must use WSS")
}
if (resourceId.isBlank()) {
throw VolcengineConfigurationException("VOLCENGINE_RESOURCE_ID must not be blank")
}
val hasNewCredential = !apiKey.isNullOrBlank()
val hasLegacyCredential = !appId.isNullOrBlank() && !accessToken.isNullOrBlank()
if (!hasNewCredential && !hasLegacyCredential) {
throw VolcengineConfigurationException(
"Configure VOLCENGINE_API_KEY or both VOLCENGINE_APP_ID and VOLCENGINE_ACCESS_TOKEN",
)
}
if (responseTimeoutMillis <= 0) {
throw VolcengineConfigurationException("Volcengine response timeout must be positive")
}
}
}
data class AsrTransportResult(
val durationMillis: Long,
val providerRequestId: String,
val hasResult: Boolean = true,
)
/**
* Transport boundary kept separate from the provider and billing orchestration,
* allowing protocol behavior and failure paths to be tested without a network.
*/
fun interface VolcengineAsrTransport {
suspend fun transcribe(
request: AsrProviderRequest,
output: ProviderOutput,
): AsrTransportResult
}
/**
* Streaming upstream boundary. Tests can inject a mock without opening a
* socket; the production implementation forwards audio frames in memory.
*/
fun interface VolcengineStreamingClient {
suspend fun transcribe(
options: AsrGatewayOptions,
audioFrames: Flow<ByteArray>,
output: ProviderOutput,
): AsrTransportResult
}
class KtorVolcengineAsrTransport(
private val client: HttpClient,
private val config: VolcengineAsrConfig,
private val codec: SaucV3Codec = SaucV3Codec(),
private val json: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
},
) : VolcengineAsrTransport, VolcengineStreamingClient {
override suspend fun transcribe(
request: AsrProviderRequest,
output: ProviderOutput,
): AsrTransportResult = transcribe(
options = request.options,
audioFrames = flow {
var start = 0
while (start < request.audio.size) {
val end = minOf(start + AUDIO_CHUNK_BYTES, request.audio.size)
emit(request.audio.copyOfRange(start, end))
start = end
}
},
output = output,
)
override suspend fun transcribe(
options: AsrGatewayOptions,
audioFrames: Flow<ByteArray>,
output: ProviderOutput,
): AsrTransportResult {
val providerRequestId = UUID.randomUUID().toString()
var finalDurationMillis: Long? = null
var finalHasResult = false
var outputBytes = 0L
var frameCount = 0
val sequenceValidator = SaucSequenceValidator()
withTimeout(config.responseTimeoutMillis) {
client.webSocket(
urlString = config.endpoint,
request = {
headers.append("X-Api-Resource-Id", config.resourceId)
headers.append("X-Api-Request-Id", providerRequestId)
headers.append("X-Api-Connect-Id", providerRequestId)
headers.append("X-Api-Sequence", "-1")
val apiKey = config.apiKey?.takeIf(String::isNotBlank)
if (apiKey != null) {
headers.append("X-Api-Key", apiKey)
} else {
headers.append("X-Api-App-Key", requireNotNull(config.appId))
headers.append("X-Api-Access-Key", requireNotNull(config.accessToken))
}
},
) {
coroutineScope {
val receiver = launch {
for (webSocketFrame in incoming) {
if (webSocketFrame !is Frame.Binary) {
throw SaucProtocolException("Volcengine returned a non-binary WebSocket frame")
}
val frame = codec.decodeServerFrame(webSocketFrame.readBytes())
sequenceValidator.accept(frame)
if (frame.type == SaucMessageType.ERROR_RESPONSE) {
throw VolcengineProviderException(
"Volcengine SAUC error ${frame.errorCode ?: "unknown"}",
)
}
frameCount += 1
if (frameCount > GatewayLimits.MAX_SSE_EVENTS) {
throw VolcengineProviderException("Volcengine returned too many ASR frames")
}
outputBytes = Math.addExact(
outputBytes,
frame.payload.size.toLong() + 1L,
)
if (outputBytes > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
throw VolcengineProviderException("Volcengine ASR output exceeded the limit")
}
if (frame.isLast) {
finalDurationMillis = extractFinalDuration(frame, json)
finalHasResult = hasRecognitionResult(frame.payload, json)
}
// The transcript is forwarded only; it is never logged or persisted.
output.emit(frame.payload + "\n".encodeToByteArray())
if (frame.isLast) {
return@launch
}
}
throw VolcengineProviderException(
"Volcengine closed before the final ASR frame",
)
}
send(
Frame.Binary(
fin = true,
data = codec.fullClientRequest(buildFullRequest(options)),
),
)
var pending: ByteArray? = null
val packet = ByteArray(AUDIO_CHUNK_BYTES)
var packetSize = 0
suspend fun queuePacket(next: ByteArray) {
pending?.let { previous ->
send(Frame.Binary(fin = true, data = codec.audioRequest(previous, false)))
delay(AUDIO_PACKET_INTERVAL_MILLIS)
}
pending = next
}
audioFrames.collect { next ->
require(next.isNotEmpty()) { "ASR audio frame must not be empty" }
require(next.size <= GatewayLimits.MAX_AUDIO_FRAME_BYTES) {
"ASR audio frame exceeds the gateway limit"
}
var offset = 0
while (offset < next.size) {
val copied = minOf(AUDIO_CHUNK_BYTES - packetSize, next.size - offset)
next.copyInto(packet, packetSize, offset, offset + copied)
packetSize += copied
offset += copied
if (packetSize == AUDIO_CHUNK_BYTES) {
queuePacket(packet.copyOf())
packetSize = 0
}
}
}
if (packetSize > 0) {
queuePacket(packet.copyOf(packetSize))
}
val finalAudio = pending
?: throw VolcengineProviderException("ASR stream contained no audio")
send(Frame.Binary(fin = true, data = codec.audioRequest(finalAudio, true)))
try {
receiver.join()
} finally {
receiver.cancel()
}
}
}
}
return AsrTransportResult(
durationMillis = requireNotNull(finalDurationMillis),
providerRequestId = providerRequestId,
hasResult = finalHasResult,
)
}
private fun buildFullRequest(options: AsrGatewayOptions): ByteArray {
val payload = FullAsrRequest(
audio = AudioOptions(
format = options.format,
codec = options.codec,
rate = options.sampleRate,
bits = options.bits,
channel = options.channels,
language = options.language,
),
request = RecognitionOptions(),
)
return json.encodeToString(payload).encodeToByteArray()
}
private companion object {
// Approximately 200 ms for 16 kHz, 16-bit mono PCM; compressed formats
// still use this bounded transport chunk size.
const val AUDIO_CHUNK_BYTES = 6_400
const val AUDIO_PACKET_INTERVAL_MILLIS = 100L
}
}
class VolcengineAsrProvider(
private val transport: VolcengineAsrTransport,
) : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "volcengine-sauc-v3",
capabilities = setOf(GatewayCapability.ASR),
streaming = true,
usageMeter = UsageMeter.AUDIO_MILLISECOND,
)
override fun accepts(request: ProviderRequest): Boolean =
request is AsrProviderRequest
override suspend fun execute(
request: ProviderRequest,
output: ProviderOutput,
): ProviderUsage {
require(request is AsrProviderRequest) { "Volcengine only accepts ASR requests" }
validate(request)
val result = transport.transcribe(request, output)
if (!result.hasResult ||
result.durationMillis !in 1..request.options.estimatedDurationMillis
) {
throw VolcengineUsageException("Volcengine returned an empty or invalid ASR result")
}
return ProviderUsage(
meter = UsageMeter.AUDIO_MILLISECOND,
units = result.durationMillis,
providerRequestId = result.providerRequestId,
)
}
private fun validate(request: AsrProviderRequest) {
require(request.audio.isNotEmpty()) { "audio must not be empty" }
require(request.audio.size <= GatewayLimits.MAX_AUDIO_BYTES) { "audio exceeds the gateway limit" }
require(request.options.estimatedDurationMillis in 1..GatewayLimits.MAX_AUDIO_MILLIS) {
"estimatedDurationMillis is out of range"
}
require(request.options.format in setOf("pcm", "wav", "ogg", "mp3")) {
"unsupported audio format"
}
require(request.options.codec in setOf("raw", "opus")) { "unsupported audio codec" }
require(request.options.sampleRate == 16_000) { "only 16000 Hz audio is supported" }
require(request.options.bits == 16) { "only 16-bit audio is supported" }
require(request.options.channels in 1..2) { "channels must be 1 or 2" }
require(request.options.format != "ogg" || request.options.codec == "opus") {
"ogg audio requires opus codec"
}
}
}
@Serializable
private data class FullAsrRequest(
val audio: AudioOptions,
val request: RecognitionOptions,
)
@Serializable
private data class AudioOptions(
val format: String,
val codec: String,
val rate: Int,
val bits: Int,
val channel: Int,
val language: String? = null,
)
@Serializable
private data class RecognitionOptions(
@SerialName("model_name")
val modelName: String = "bigmodel",
@SerialName("result_type")
val resultType: String = "full",
@SerialName("show_utterances")
val showUtterances: Boolean = true,
)
class VolcengineConfigurationException(message: String) : IllegalStateException(message)
class VolcengineProviderException(message: String) : RuntimeException(message)
class VolcengineUsageException(message: String) : ProviderCompletionException(message)
internal fun extractFinalDuration(
frame: SaucFrame,
json: Json = Json { ignoreUnknownKeys = true },
): Long {
if (!frame.isLast) {
throw VolcengineUsageException("Only the final ASR frame may provide billable duration")
}
val duration = runCatching {
json.parseToJsonElement(frame.payload.decodeToString())
.jsonObject["audio_info"]
?.jsonObject
?.get("duration")
?.jsonPrimitive
?.content
?.toLong()
}.getOrNull()
?: throw VolcengineUsageException("Final ASR response omitted audio_info.duration")
if (duration !in 0..GatewayLimits.MAX_AUDIO_MILLIS) {
throw VolcengineUsageException("Final ASR duration is outside the allowed range")
}
return duration
}
internal fun hasRecognitionResult(
payload: ByteArray,
json: Json = Json { ignoreUnknownKeys = true },
): Boolean = runCatching {
val result = json.parseToJsonElement(payload.decodeToString()).jsonObject["result"]
when (result) {
is JsonObject -> result.hasRecognizedText()
is JsonArray -> result.any { (it as? JsonObject)?.hasRecognizedText() == true }
null, JsonNull -> false
else -> false
}
}.getOrDefault(false)
private fun JsonObject.hasRecognizedText(): Boolean {
val directText = this["text"]
?.let { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() }
if (!directText.isNullOrBlank()) return true
return (this["utterances"] as? JsonArray).orEmpty().any { utterance ->
(utterance as? JsonObject)
?.get("text")
?.let { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() }
?.isNotBlank() == true
}
}
@@ -0,0 +1,485 @@
package com.osglab.account.features.gateway.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayGrant
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
import com.osglab.account.features.gateway.ports.GatewayRefreshRotationResult
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.ports.NewGatewayGrant
import com.osglab.account.features.gateway.ports.PendingSettlement
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
import com.osglab.account.features.gateway.ports.ProviderRequestState
import com.osglab.account.features.gateway.ports.StoredGatewayRefresh
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
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 java.time.Clock
private object ProviderRequestsTable : Table("provider_requests") {
val requestId = varchar("request_id", 64)
val accountId = varchar("account_id", 36)
val reservationId = varchar("reservation_id", 36).nullable()
val providerId = varchar("provider_id", 64)
val capability = varchar("capability", 32)
val status = varchar("status", 24)
val providerRequestId = varchar("provider_request_id", 128).nullable()
val usageMeter = varchar("usage_meter", 32).nullable()
val usageUnits = long("usage_units").nullable()
val usageInputUnits = long("usage_input_units").nullable()
val usageOutputUnits = long("usage_output_units").nullable()
val serverDurationMillis = long("server_duration_millis").nullable()
val errorCode = varchar("error_code", 96).nullable()
val createdAt = timestamp("created_at")
val completedAt = timestamp("completed_at").nullable()
override val primaryKey = PrimaryKey(accountId, requestId)
}
private object UsageRecordsTable : Table("usage_records") {
val id = long("id").autoIncrement()
val accountId = varchar("account_id", 36)
val requestId = varchar("request_id", 64)
val meter = varchar("meter", 32)
val units = long("units")
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object GatewayGrantsTable : Table("gateway_grants") {
val id = varchar("id", 36)
val accountId = varchar("account_id", 36)
val idempotencyKey = varchar("idempotency_key", 128)
val expiresAt = timestamp("expires_at")
val revokedAt = timestamp("revoked_at").nullable()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
private object GatewayGrantScopesTable : Table("gateway_grant_scopes") {
val grantId = varchar("grant_id", 36)
val capability = varchar("capability", 32)
override val primaryKey = PrimaryKey(grantId, capability)
}
private object GatewayRefreshTokensTable : Table("gateway_refresh_tokens") {
val id = varchar("id", 36)
val grantId = varchar("grant_id", 36)
val familyId = varchar("family_id", 36)
val tokenHash = char("token_hash", 64)
val replacedById = varchar("replaced_by_id", 36).nullable()
val rotationIdempotencyKey = varchar("rotation_idempotency_key", 128).nullable()
val expiresAt = timestamp("expires_at")
val revokedAt = timestamp("revoked_at").nullable()
val reuseDetectedAt = timestamp("reuse_detected_at").nullable()
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
class ExposedGatewayRepository(
private val databaseFactory: DatabaseFactory,
private val clock: Clock = Clock.systemUTC(),
) : GatewayGrantRepository, GatewayUsagePort {
override suspend fun isAllowed(accountId: String, capability: GatewayCapability): Boolean =
databaseFactory.query {
val now = clock.instant()
GatewayGrantsTable.selectAll()
.where {
(GatewayGrantsTable.accountId eq accountId) and
GatewayGrantsTable.revokedAt.isNull() and
(GatewayGrantsTable.expiresAt greater now)
}
.any { row ->
GatewayGrantScopesTable.selectAll()
.where {
(GatewayGrantScopesTable.grantId eq row[GatewayGrantsTable.id]) and
(GatewayGrantScopesTable.capability eq capability.name)
}
.limit(1)
.singleOrNull() != null
}
}
override suspend fun create(grant: NewGatewayGrant, now: java.time.Instant): StoredGatewayRefresh =
databaseFactory.query {
val inserted = GatewayGrantsTable.insertIgnore {
it[id] = grant.id
it[accountId] = grant.accountId
it[idempotencyKey] = grant.idempotencyKey
it[expiresAt] = grant.expiresAt
it[createdAt] = now
it[updatedAt] = now
}.insertedCount == 1
if (!inserted) {
val existing = GatewayGrantsTable.selectAll()
.where {
(GatewayGrantsTable.accountId eq grant.accountId) and
(GatewayGrantsTable.idempotencyKey eq grant.idempotencyKey)
}
.forUpdate()
.single()
val stored = existing.toGrant()
require(stored.scopes == grant.scopes) {
"Idempotency key was already used with a different gateway grant"
}
return@query activeRefresh(stored)
}
grant.scopes.forEach { scope ->
GatewayGrantScopesTable.insert {
it[grantId] = grant.id
it[capability] = scope.name
}
}
GatewayRefreshTokensTable.insert {
it[id] = grant.refreshTokenId
it[grantId] = grant.id
it[familyId] = grant.refreshFamilyId
it[tokenHash] = grant.refreshTokenHash
it[expiresAt] = minOf(grant.refreshExpiresAt, grant.expiresAt)
it[createdAt] = now
}
StoredGatewayRefresh(
grant = GatewayGrant(grant.id, grant.accountId, grant.scopes, grant.expiresAt),
tokenId = grant.refreshTokenId,
familyId = grant.refreshFamilyId,
expiresAt = minOf(grant.refreshExpiresAt, grant.expiresAt),
)
}
override suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: java.time.Instant,
now: java.time.Instant,
): GatewayRefreshRotationResult = databaseFactory.query {
val current = GatewayRefreshTokensTable.selectAll()
.where { GatewayRefreshTokensTable.tokenHash eq currentTokenHash }
.forUpdate()
.singleOrNull()
?: return@query GatewayRefreshRotationResult.Invalid
val grantRow = GatewayGrantsTable.selectAll()
.where { GatewayGrantsTable.id eq current[GatewayRefreshTokensTable.grantId] }
.forUpdate()
.single()
current[GatewayRefreshTokensTable.replacedById]?.let { replacedBy ->
if (current[GatewayRefreshTokensTable.rotationIdempotencyKey] == rotationIdempotencyKey) {
val replacement = GatewayRefreshTokensTable.selectAll()
.where { GatewayRefreshTokensTable.id eq replacedBy }
.single()
return@query GatewayRefreshRotationResult.Rotated(
replacement.toStoredRefresh(grantRow.toGrant()),
)
}
GatewayRefreshTokensTable.update({
GatewayRefreshTokensTable.familyId eq current[GatewayRefreshTokensTable.familyId]
}) {
it[revokedAt] = now
}
GatewayRefreshTokensTable.update({ GatewayRefreshTokensTable.id eq current[GatewayRefreshTokensTable.id] }) {
it[reuseDetectedAt] = now
}
GatewayGrantsTable.update({ GatewayGrantsTable.id eq grantRow[GatewayGrantsTable.id] }) {
it[revokedAt] = now
it[updatedAt] = now
}
return@query GatewayRefreshRotationResult.ReuseDetected
}
if (current[GatewayRefreshTokensTable.revokedAt] != null ||
!current[GatewayRefreshTokensTable.expiresAt].isAfter(now) ||
grantRow[GatewayGrantsTable.revokedAt] != null ||
!grantRow[GatewayGrantsTable.expiresAt].isAfter(now)
) {
GatewayRefreshTokensTable.update({ GatewayRefreshTokensTable.id eq current[GatewayRefreshTokensTable.id] }) {
it[revokedAt] = now
}
return@query GatewayRefreshRotationResult.Invalid
}
val expiresAt = minOf(newExpiresAt, grantRow[GatewayGrantsTable.expiresAt])
GatewayRefreshTokensTable.insert {
it[id] = newTokenId
it[grantId] = current[GatewayRefreshTokensTable.grantId]
it[familyId] = current[GatewayRefreshTokensTable.familyId]
it[tokenHash] = newTokenHash
it[GatewayRefreshTokensTable.expiresAt] = expiresAt
it[createdAt] = now
}
GatewayRefreshTokensTable.update({ GatewayRefreshTokensTable.id eq current[GatewayRefreshTokensTable.id] }) {
it[replacedById] = newTokenId
it[GatewayRefreshTokensTable.rotationIdempotencyKey] = rotationIdempotencyKey
it[revokedAt] = now
}
GatewayRefreshRotationResult.Rotated(
StoredGatewayRefresh(
grant = grantRow.toGrant(),
tokenId = newTokenId,
familyId = current[GatewayRefreshTokensTable.familyId],
expiresAt = expiresAt,
),
)
}
override suspend fun revoke(accountId: String, grantId: String, now: java.time.Instant): Boolean =
databaseFactory.query {
val changed = GatewayGrantsTable.update({
(GatewayGrantsTable.id eq grantId) and
(GatewayGrantsTable.accountId eq accountId) and
GatewayGrantsTable.revokedAt.isNull()
}) {
it[revokedAt] = now
it[updatedAt] = now
}
if (changed > 0) {
GatewayRefreshTokensTable.update({ GatewayRefreshTokensTable.grantId eq grantId }) {
it[revokedAt] = now
}
}
changed > 0
}
override suspend fun findActive(
grantId: String,
accountId: String,
scopes: Set<GatewayCapability>,
now: java.time.Instant,
): GatewayGrant? = databaseFactory.query {
GatewayGrantsTable.selectAll()
.where {
(GatewayGrantsTable.id eq grantId) and
(GatewayGrantsTable.accountId eq accountId) and
GatewayGrantsTable.revokedAt.isNull() and
(GatewayGrantsTable.expiresAt greater now)
}
.singleOrNull()
?.toGrant()
?.takeIf { it.scopes == scopes }
}
override suspend fun claim(metadata: ProviderRequestMetadata) {
databaseFactory.query {
val inserted = ProviderRequestsTable.insertIgnore {
it[requestId] = metadata.requestId
it[accountId] = metadata.accountId
it[reservationId] = metadata.reservationId
it[providerId] = metadata.providerId
it[capability] = metadata.capability.name
it[status] = ProviderRequestState.CLAIMED.name
it[createdAt] = clock.instant()
}.insertedCount == 1
if (!inserted) {
val existing = ProviderRequestsTable.selectAll()
.where {
(ProviderRequestsTable.accountId eq metadata.accountId) and
(ProviderRequestsTable.requestId eq metadata.requestId)
}
.single()
throw GatewayRequestAlreadyClaimedException(existing.requestState())
}
}
}
override suspend fun markStarted(accountId: String, requestId: String) {
transition(accountId, requestId, ProviderRequestState.CLAIMED, ProviderRequestState.STARTED)
}
override suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
) {
databaseFactory.query {
val changed = ProviderRequestsTable.update({
requestKey(accountId, requestId) and
(ProviderRequestsTable.status eq ProviderRequestState.STARTED.name)
}) {
it[status] = ProviderRequestState.SETTLEMENT_PENDING.name
it[providerRequestId] = usage.providerRequestId
it[usageMeter] = usage.meter.name
it[usageUnits] = usage.units
it[usageInputUnits] = usage.inputUnits
it[usageOutputUnits] = usage.outputUnits
it[serverDurationMillis] = usage.serverDurationMillis
it[errorCode] = null
}
check(changed == 1) { "Gateway request cannot enter settlement pending" }
}
}
override suspend fun markSucceeded(
accountId: String,
requestId: String,
usage: ProviderUsage,
) {
databaseFactory.query {
val now = clock.instant()
val inserted = UsageRecordsTable.insertIgnore {
it[UsageRecordsTable.accountId] = accountId
it[UsageRecordsTable.requestId] = requestId
it[meter] = usage.meter.name
it[units] = usage.units
it[createdAt] = now
}.insertedCount == 1
if (!inserted) {
val existing = UsageRecordsTable.selectAll()
.where {
(UsageRecordsTable.accountId eq accountId) and
(UsageRecordsTable.requestId eq requestId) and
(UsageRecordsTable.meter eq usage.meter.name)
}
.single()
check(existing[UsageRecordsTable.units] == usage.units) {
"Usage retry differs from the recorded value"
}
}
val changed = ProviderRequestsTable.update({
requestKey(accountId, requestId) and
(
(ProviderRequestsTable.status eq ProviderRequestState.SETTLEMENT_PENDING.name) or
(ProviderRequestsTable.status eq ProviderRequestState.SETTLED.name)
)
}) {
it[status] = ProviderRequestState.SETTLED.name
it[providerRequestId] = usage.providerRequestId
it[completedAt] = now
it[errorCode] = null
}
check(changed == 1) { "Gateway request cannot be marked succeeded" }
}
}
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) {
databaseFactory.query {
val changed = ProviderRequestsTable.update({
requestKey(accountId, requestId) and
(
(ProviderRequestsTable.status eq ProviderRequestState.CLAIMED.name) or
(ProviderRequestsTable.status eq ProviderRequestState.STARTED.name)
)
}) {
it[status] = ProviderRequestState.RELEASED.name
it[ProviderRequestsTable.errorCode] = errorCode.take(96)
it[completedAt] = clock.instant()
}
check(changed == 1) { "Gateway request cannot be released" }
}
}
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) {
databaseFactory.query {
val changed = ProviderRequestsTable.update({
requestKey(accountId, requestId) and
(
(ProviderRequestsTable.status eq ProviderRequestState.CLAIMED.name) or
(ProviderRequestsTable.status eq ProviderRequestState.STARTED.name) or
(ProviderRequestsTable.status eq ProviderRequestState.SETTLEMENT_PENDING.name)
)
}) {
it[status] = ProviderRequestState.MANUAL_REVIEW.name
it[ProviderRequestsTable.errorCode] = errorCode.take(96)
}
check(changed == 1) { "Gateway request cannot enter manual review" }
}
}
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> {
require(limit in 1..1_000)
return databaseFactory.query {
ProviderRequestsTable.selectAll()
.where { ProviderRequestsTable.status eq ProviderRequestState.SETTLEMENT_PENDING.name }
.orderBy(ProviderRequestsTable.createdAt)
.limit(limit)
.map { row ->
PendingSettlement(
requestId = row[ProviderRequestsTable.requestId],
accountId = row[ProviderRequestsTable.accountId],
reservationId = requireNotNull(row[ProviderRequestsTable.reservationId]),
usage = ProviderUsage(
meter = UsageMeter.valueOf(requireNotNull(row[ProviderRequestsTable.usageMeter])),
units = requireNotNull(row[ProviderRequestsTable.usageUnits]),
providerRequestId = row[ProviderRequestsTable.providerRequestId],
inputUnits = row[ProviderRequestsTable.usageInputUnits],
outputUnits = row[ProviderRequestsTable.usageOutputUnits],
serverDurationMillis = row[ProviderRequestsTable.serverDurationMillis] ?: 0,
),
)
}
}
}
private suspend fun transition(
accountId: String,
requestId: String,
from: ProviderRequestState,
to: ProviderRequestState,
) {
databaseFactory.query {
val changed = ProviderRequestsTable.update({
requestKey(accountId, requestId) and (ProviderRequestsTable.status eq from.name)
}) {
it[status] = to.name
}
check(changed == 1) { "Gateway request cannot transition from $from to $to" }
}
}
private fun org.jetbrains.exposed.v1.core.ResultRow.toGrant(): GatewayGrant {
val grantId = this[GatewayGrantsTable.id]
val scopes = GatewayGrantScopesTable.selectAll()
.where { GatewayGrantScopesTable.grantId eq grantId }
.map { GatewayCapability.valueOf(it[GatewayGrantScopesTable.capability]) }
.toSet()
return GatewayGrant(
id = grantId,
accountId = this[GatewayGrantsTable.accountId],
scopes = scopes,
expiresAt = this[GatewayGrantsTable.expiresAt],
revokedAt = this[GatewayGrantsTable.revokedAt],
)
}
private fun activeRefresh(grant: GatewayGrant): StoredGatewayRefresh {
val row = GatewayRefreshTokensTable.selectAll()
.where {
(GatewayRefreshTokensTable.grantId eq grant.id) and
GatewayRefreshTokensTable.replacedById.isNull() and
GatewayRefreshTokensTable.revokedAt.isNull()
}
.singleOrNull()
?: throw IllegalStateException("Idempotent gateway grant has no active refresh token")
return row.toStoredRefresh(grant)
}
private fun org.jetbrains.exposed.v1.core.ResultRow.toStoredRefresh(
grant: GatewayGrant,
): StoredGatewayRefresh = StoredGatewayRefresh(
grant = grant,
tokenId = this[GatewayRefreshTokensTable.id],
familyId = this[GatewayRefreshTokensTable.familyId],
expiresAt = this[GatewayRefreshTokensTable.expiresAt],
)
}
private fun requestKey(accountId: String, requestId: String) =
(ProviderRequestsTable.accountId eq accountId) and
(ProviderRequestsTable.requestId eq requestId)
private fun org.jetbrains.exposed.v1.core.ResultRow.requestState(): ProviderRequestState =
runCatching { ProviderRequestState.valueOf(this[ProviderRequestsTable.status]) }
.getOrDefault(ProviderRequestState.MANUAL_REVIEW)
@@ -0,0 +1,568 @@
package com.osglab.account.features.gateway.routes
import com.osglab.account.features.gateway.asr.AsrConcurrencyLimitException
import com.osglab.account.features.gateway.asr.AsrSessionAlreadyUsedException
import com.osglab.account.features.gateway.asr.AsrSessionNotFoundException
import com.osglab.account.features.gateway.asr.AsrStreamingService
import com.osglab.account.features.gateway.asr.CreateAsrSessionRequest
import com.osglab.account.features.gateway.models.AsrGatewayOptions
import com.osglab.account.features.gateway.models.AsrProviderRequest
import com.osglab.account.features.gateway.models.AudioDurationPolicy
import com.osglab.account.features.gateway.models.CreateGatewayGrantRequest
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayCatalogResponse
import com.osglab.account.features.gateway.models.GatewayErrorResponse
import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.GatewaySubject
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.RefreshGatewayGrantRequest
import com.osglab.account.features.gateway.models.TextGatewayRequest
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.TextRequestPolicy
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
import com.osglab.account.features.gateway.ports.GatewayIdentityPort
import com.osglab.account.features.gateway.ports.GatewayPrincipalResolver
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
import com.osglab.account.features.gateway.providers.UnsupportedGatewayCapabilityException
import com.osglab.account.features.gateway.services.GatewayAccessDeniedException
import com.osglab.account.features.gateway.services.GatewayGrantService
import com.osglab.account.features.gateway.services.GatewayRefreshTokenInvalidException
import com.osglab.account.features.gateway.services.GatewayRefreshTokenReuseException
import com.osglab.account.features.gateway.services.GatewayService
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.receive
import io.ktor.server.request.receiveChannel
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.response.respondBytesWriter
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.server.websocket.webSocket
import io.ktor.utils.io.writeFully
import io.ktor.utils.io.readRemaining
import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readBytes
import io.ktor.websocket.readText
import io.ktor.websocket.send
import java.io.ByteArrayOutputStream
import java.util.UUID
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withTimeout
import kotlinx.io.readByteArray
import kotlinx.serialization.json.Json
fun Route.configureGatewayRoutes(
service: GatewayService,
appIdentity: GatewayIdentityPort,
gatewayIdentity: GatewayAccessTokenPort,
grantService: GatewayGrantService? = null,
asrStreaming: AsrStreamingService? = null,
) {
route("/v1/gateway") {
if (grantService != null) {
post("/grants") {
val requestId = call.gatewayRequestId()
val principal = call.requireSubject(appIdentity, requestId) ?: return@post
val idempotencyKey = call.request.headers[IDEMPOTENCY_HEADER]
?: return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"missing_idempotency_key",
"$IDEMPOTENCY_HEADER is required",
requestId,
)
val body = runCatching {
ROUTE_JSON.decodeFromString<CreateGatewayGrantRequest>(
call.receiveBounded(GatewayLimits.MAX_JSON_BODY_BYTES).decodeToString(),
)
}.getOrElse {
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
return@post call.respondGatewayFailure(it, requestId)
}
return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request",
"Gateway grant request is invalid",
requestId,
)
}
runCatching { grantService.create(principal, body, idempotencyKey) }
.onSuccess { call.respond(HttpStatusCode.Created, it) }
.onFailure { call.respondGatewayFailure(it, requestId) }
}
post("/grants/refresh") {
val requestId = call.gatewayRequestId()
val idempotencyKey = call.request.headers[IDEMPOTENCY_HEADER]
?: return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"missing_idempotency_key",
"$IDEMPOTENCY_HEADER is required",
requestId,
)
val body = runCatching {
ROUTE_JSON.decodeFromString<RefreshGatewayGrantRequest>(
call.receiveBounded(GatewayLimits.MAX_JSON_BODY_BYTES).decodeToString(),
)
}.getOrElse {
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
return@post call.respondGatewayFailure(it, requestId)
}
return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request",
"Gateway refresh request is invalid",
requestId,
)
}
runCatching { grantService.refresh(body.refreshToken, idempotencyKey) }
.onSuccess { call.respond(it) }
.onFailure { call.respondGatewayFailure(it, requestId) }
}
delete("/grants/{grantId}") {
val requestId = call.gatewayRequestId()
val principal = call.requireSubject(appIdentity, requestId) ?: return@delete
val grantId = call.parameters["grantId"]
?: return@delete call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_grant",
"Grant ID is required",
requestId,
)
runCatching { grantService.revoke(principal, grantId) }
.onSuccess { call.respond(HttpStatusCode.NoContent) }
.onFailure { call.respondGatewayFailure(it, requestId) }
}
}
get("/catalog") {
val requestId = call.gatewayRequestId()
call.requireSubject(gatewayIdentity, requestId) ?: return@get
call.respond(GatewayCatalogResponse(service.catalog()))
}
if (asrStreaming != null) {
post("/asr/sessions") {
val requestId = call.requireProviderRequestId() ?: return@post
val principal = call.requireSubject(gatewayIdentity, requestId) ?: return@post
val request = runCatching {
ROUTE_JSON.decodeFromString<CreateAsrSessionRequest>(
call.receiveBounded(GatewayLimits.MAX_JSON_BODY_BYTES).decodeToString(),
)
}.getOrElse {
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
return@post call.respondGatewayFailure(it, requestId)
}
return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request",
"ASR session request is invalid",
requestId,
)
}
try {
call.respond(HttpStatusCode.Created, asrStreaming.createSession(principal, requestId, request))
} catch (failure: Throwable) {
call.respondGatewayFailure(failure, requestId)
}
}
webSocket("/asr/sessions/{sessionId}/stream") {
val principal = gatewayIdentity.resolve(call)
if (principal == null) {
close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Authentication required"))
return@webSocket
}
val sessionId = call.parameters["sessionId"]
if (sessionId == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Session is required"))
return@webSocket
}
try {
val audio = flow {
while (true) {
when (val frame = withTimeout(asrStreaming.limits.idleTimeoutMillis) {
incoming.receive()
}) {
is Frame.Binary -> {
if (!frame.fin || frame.data.size > asrStreaming.limits.maxFrameBytes) {
throw IllegalArgumentException("ASR frame exceeds the gateway limit")
}
emit(frame.readBytes())
}
is Frame.Text -> {
if (frame.readText() == """{"type":"end"}""") break
throw IllegalArgumentException("Unsupported ASR control frame")
}
is Frame.Close ->
throw CancellationException("Downstream ASR connection closed")
else -> throw IllegalArgumentException("Unsupported ASR WebSocket frame")
}
}
}
asrStreaming.stream(
sessionId = sessionId,
principal = principal,
audioFrames = audio,
output = ProviderOutput { bytes ->
send(Frame.Binary(fin = true, data = bytes))
},
)
close(CloseReason(CloseReason.Codes.NORMAL, "Complete"))
} catch (failure: CancellationException) {
throw failure
} catch (_: Throwable) {
// Error metadata only; never echo audio or transcript content.
send("""{"type":"gateway_error","code":"asr_failed"}""")
close(CloseReason(CloseReason.Codes.INTERNAL_ERROR, "ASR failed"))
}
}
}
post("/llm/{capability}") {
val requestId = call.requireProviderRequestId() ?: return@post
val subject = call.requireSubject(gatewayIdentity, requestId) ?: return@post
val capability = call.parameters["capability"].toTextCapability()
?: return@post call.respondGatewayError(
HttpStatusCode.NotFound,
"unknown_capability",
"Only polish, ai and agent are supported",
requestId,
)
val body = runCatching {
ROUTE_JSON.decodeFromString<TextGatewayRequest>(
call.receiveBounded(GatewayLimits.MAX_JSON_BODY_BYTES).decodeToString(),
).also { TextRequestPolicy.validate(it, capability) }
}
.getOrElse {
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
return@post call.respondGatewayFailure(it, requestId)
}
return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request",
"Request body is invalid",
requestId,
)
}
val providerRequest = TextProviderRequest(
requestId = requestId,
capability = capability,
input = body.input,
context = body.context,
maxOutputTokens = body.maxOutputTokens,
temperature = body.temperature,
stream = body.stream,
)
if (body.stream) {
val prepared = try {
service.prepare(subject, providerRequest)
} catch (failure: Throwable) {
call.respondGatewayFailure(failure, requestId)
return@post
}
var executionStarted = false
try {
call.respondBytesWriter(ContentType.Text.EventStream) {
executionStarted = true
var emittedBytes = 0L
try {
service.executePrepared(prepared, ProviderOutput { bytes ->
emittedBytes = Math.addExact(emittedBytes, bytes.size.toLong())
if (emittedBytes > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
throw GatewayOutputLimitException()
}
writeFully(bytes)
flush()
})
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
// The response may already be committed. Emit metadata only.
val errorEvent =
"event: gateway_error\ndata: {\"code\":\"provider_error\",\"requestId\":\"$requestId\"}\n\n"
writeFully(errorEvent.encodeToByteArray())
flush()
}
}
} catch (failure: Throwable) {
if (!executionStarted) {
service.releasePrepared(prepared, failure)
}
throw failure
}
} else {
call.executeBuffered(
service = service,
subject = subject,
request = providerRequest,
contentType = ContentType.Application.Json,
)
}
}
post("/asr") {
val requestId = call.requireProviderRequestId() ?: return@post
val subject = call.requireSubject(gatewayIdentity, requestId) ?: return@post
val estimatedDuration = call.request.headers["X-Audio-Duration-Ms"]?.toLongOrNull()
?: return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"missing_audio_duration",
"X-Audio-Duration-Ms is required",
requestId,
)
val audio = runCatching {
call.receiveBounded(GatewayLimits.MAX_AUDIO_BYTES)
}
.getOrElse {
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
return@post call.respondGatewayFailure(it, requestId)
}
return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_audio",
"Audio body is invalid",
requestId,
)
}
val rawOptions = AsrGatewayOptions(
format = call.request.headers["X-Audio-Format"] ?: "pcm",
codec = call.request.headers["X-Audio-Codec"] ?: "raw",
sampleRate = call.request.headers["X-Audio-Sample-Rate"]?.toIntOrNull() ?: 16_000,
bits = call.request.headers["X-Audio-Bits"]?.toIntOrNull() ?: 16,
channels = call.request.headers["X-Audio-Channels"]?.toIntOrNull() ?: 1,
language = call.request.headers["X-Audio-Language"]?.take(32),
estimatedDurationMillis = estimatedDuration,
)
val options = runCatching {
rawOptions.copy(
estimatedDurationMillis = AudioDurationPolicy.reservationMillis(
audio.size,
rawOptions,
),
)
}.getOrElse {
return@post call.respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_audio_duration",
it.message ?: "Audio duration is invalid",
requestId,
)
}
val request = AsrProviderRequest(
requestId = requestId,
options = options,
audio = audio,
)
call.executeBuffered(
service = service,
subject = subject,
request = request,
contentType = ContentType.parse("application/x-ndjson"),
)
}
}
}
private suspend fun ApplicationCall.executeBuffered(
service: GatewayService,
subject: GatewaySubject,
request: com.osglab.account.features.gateway.models.ProviderRequest,
contentType: ContentType,
) {
val output = ByteArrayOutputStream(minOf(64 * 1024, GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES))
var emittedBytes = 0L
try {
service.execute(subject, request, ProviderOutput { bytes ->
emittedBytes = Math.addExact(emittedBytes, bytes.size.toLong())
if (emittedBytes > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
throw GatewayOutputLimitException()
}
output.write(bytes)
})
respondBytes(output.toByteArray(), contentType)
} catch (failure: Throwable) {
respondGatewayFailure(failure, request.requestId)
}
}
private suspend fun ApplicationCall.requireSubject(
identity: GatewayPrincipalResolver,
requestId: String,
): GatewaySubject? {
val subject = identity.resolve(this)
if (subject == null) {
respondGatewayError(
HttpStatusCode.Unauthorized,
"unauthorized",
"Authentication is required",
requestId,
)
}
return subject
}
private suspend fun ApplicationCall.respondGatewayFailure(
failure: Throwable,
requestId: String,
) {
when (failure) {
is GatewayRequestAlreadyClaimedException -> respondGatewayError(
HttpStatusCode.Conflict,
"request_already_claimed",
"This account request ID is already ${failure.state.name.lowercase()}",
requestId,
)
is GatewayBodyTooLargeException -> respondGatewayError(
HttpStatusCode.PayloadTooLarge,
"request_too_large",
"Request body exceeds the gateway limit",
requestId,
)
is GatewayRequestTimeoutException -> respondGatewayError(
HttpStatusCode.RequestTimeout,
"request_timeout",
"Request body was not received within the time limit",
requestId,
)
is GatewayAccessDeniedException -> respondGatewayError(
HttpStatusCode.Forbidden,
"gateway_grant_denied",
"Gateway access is not granted",
requestId,
)
is GatewayRefreshTokenInvalidException,
is GatewayRefreshTokenReuseException -> respondGatewayError(
HttpStatusCode.Unauthorized,
"invalid_gateway_refresh",
"Gateway refresh token is invalid",
requestId,
)
is AsrConcurrencyLimitException -> respondGatewayError(
HttpStatusCode.TooManyRequests,
"asr_concurrency_limit",
"Too many concurrent ASR sessions",
requestId,
)
is AsrSessionNotFoundException,
is AsrSessionAlreadyUsedException -> respondGatewayError(
HttpStatusCode.NotFound,
"asr_session_unavailable",
"ASR session is unavailable",
requestId,
)
is UnsupportedGatewayCapabilityException -> respondGatewayError(
HttpStatusCode.ServiceUnavailable,
"provider_unavailable",
"No provider is configured for this capability",
requestId,
)
is IllegalArgumentException -> respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request",
failure.message ?: "Request is invalid",
requestId,
)
else -> respondGatewayError(
HttpStatusCode.BadGateway,
"gateway_failure",
"The managed provider request failed",
requestId,
)
}
}
private suspend fun ApplicationCall.respondGatewayError(
status: HttpStatusCode,
code: String,
message: String,
requestId: String,
) {
respond(status, GatewayErrorResponse(code, message, requestId))
}
private fun ApplicationCall.gatewayRequestId(): String {
val supplied = request.headers[REQUEST_ID_HEADER]
return supplied?.takeIf { REQUEST_ID.matches(it) } ?: UUID.randomUUID().toString()
}
private suspend fun ApplicationCall.requireProviderRequestId(): String? {
val key = request.headers[REQUEST_ID_HEADER]
val responseRequestId = key?.takeIf(REQUEST_ID::matches) ?: UUID.randomUUID().toString()
if (key == null) {
respondGatewayError(
HttpStatusCode.BadRequest,
"missing_request_id",
"$REQUEST_ID_HEADER is required for idempotent provider requests",
responseRequestId,
)
return null
}
if (!REQUEST_ID.matches(key)) {
respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request_id",
"$REQUEST_ID_HEADER is invalid",
responseRequestId,
)
return null
}
return key
}
private suspend fun ApplicationCall.receiveBounded(maxBytes: Int): ByteArray {
val declared = request.headers[io.ktor.http.HttpHeaders.ContentLength]?.toLongOrNull()
if (declared != null && declared > maxBytes) throw GatewayBodyTooLargeException()
val bytes = try {
withTimeout(REQUEST_BODY_TIMEOUT_MILLIS) {
receiveChannel()
.readRemaining(maxBytes.toLong() + 1L)
.readByteArray()
}
} catch (_: TimeoutCancellationException) {
throw GatewayRequestTimeoutException()
}
if (bytes.size > maxBytes) throw GatewayBodyTooLargeException()
return bytes
}
private fun String?.toTextCapability(): GatewayCapability? =
when (this) {
"polish" -> GatewayCapability.POLISH
"ai" -> GatewayCapability.AI
"agent" -> GatewayCapability.AGENT
else -> null
}
private val REQUEST_ID = Regex("[A-Za-z0-9_-]{8,64}")
private const val REQUEST_ID_HEADER = "X-Request-ID"
private const val IDEMPOTENCY_HEADER = "Idempotency-Key"
private val ROUTE_JSON = Json {
ignoreUnknownKeys = false
explicitNulls = false
}
private class GatewayBodyTooLargeException : IllegalArgumentException()
private class GatewayRequestTimeoutException : RuntimeException()
private class GatewayOutputLimitException : RuntimeException("Provider output exceeds the gateway limit")
private const val REQUEST_BODY_TIMEOUT_MILLIS = 30_000L
@@ -0,0 +1,247 @@
package com.osglab.account.features.gateway.services
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.MACSigner
import com.nimbusds.jose.crypto.MACVerifier
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.features.gateway.GatewaySettings
import com.osglab.account.features.gateway.models.CreateGatewayGrantRequest
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayGrant
import com.osglab.account.features.gateway.models.GatewayGrantTokens
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
import com.osglab.account.features.gateway.ports.GatewayRefreshRotationResult
import com.osglab.account.features.gateway.ports.NewGatewayGrant
import com.osglab.account.features.gateway.ports.StoredGatewayRefresh
import io.ktor.http.HttpHeaders
import io.ktor.server.application.ApplicationCall
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.Date
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
class GatewayGrantService(
private val repository: GatewayGrantRepository,
private val settings: GatewaySettings,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun create(
principal: GatewayPrincipal,
request: CreateGatewayGrantRequest,
idempotencyKey: String,
): GatewayGrantTokens {
validateIdempotencyKey(idempotencyKey)
require(request.scopes.isNotEmpty()) { "At least one gateway scope is required" }
require(principal.scopes.containsAll(request.scopes)) {
"Requested gateway scopes exceed the issuing identity"
}
val lifetime = request.lifetimeSeconds
?.let(Duration::ofSeconds)
?: settings.maximumGrantLifetime
require(lifetime >= settings.accessTokenLifetime && lifetime <= settings.maximumGrantLifetime) {
"Gateway grant lifetime is outside the allowed range"
}
val now = clock.instant()
val grantId = UUID.randomUUID().toString()
val tokenId = UUID.randomUUID().toString()
val familyId = UUID.randomUUID().toString()
val grantExpiresAt = now.plus(lifetime)
val refreshExpiresAt = minOf(now.plus(settings.refreshTokenLifetime), grantExpiresAt)
val refreshToken = refreshToken(grantId, familyId, tokenId)
val stored = repository.create(
NewGatewayGrant(
id = grantId,
accountId = principal.accountId,
idempotencyKey = idempotencyKey,
scopes = request.scopes,
expiresAt = grantExpiresAt,
refreshTokenId = tokenId,
refreshFamilyId = familyId,
refreshTokenHash = tokenHash(refreshToken),
refreshExpiresAt = refreshExpiresAt,
),
now,
)
return issue(stored)
}
suspend fun refresh(refreshToken: String, idempotencyKey: String): GatewayGrantTokens {
validateIdempotencyKey(idempotencyKey)
if (refreshToken.length !in 32..MAX_REFRESH_TOKEN_CHARS) {
throw GatewayRefreshTokenInvalidException()
}
val newTokenId = UUID.randomUUID().toString()
val parsed = parseRefreshToken(refreshToken)
val newToken = refreshToken(parsed.grantId, parsed.familyId, newTokenId)
val now = clock.instant()
val result = repository.rotateRefresh(
currentTokenHash = tokenHash(refreshToken),
rotationIdempotencyKey = idempotencyKey,
newTokenId = newTokenId,
newTokenHash = tokenHash(newToken),
newExpiresAt = now.plus(settings.refreshTokenLifetime),
now = now,
)
return when (result) {
is GatewayRefreshRotationResult.Rotated -> issue(result.refresh)
GatewayRefreshRotationResult.Invalid -> throw GatewayRefreshTokenInvalidException()
GatewayRefreshRotationResult.ReuseDetected -> throw GatewayRefreshTokenReuseException()
}
}
suspend fun revoke(principal: GatewayPrincipal, grantId: String): Boolean {
require(runCatching { UUID.fromString(grantId) }.isSuccess) { "Grant ID is invalid" }
return repository.revoke(principal.accountId, grantId, clock.instant())
}
suspend fun authenticate(serialized: String): GatewayPrincipal? {
val principal = verifyAccessToken(serialized) ?: return null
return repository.findActive(
grantId = requireNotNull(principal.grantId),
accountId = principal.accountId,
scopes = principal.scopes,
now = clock.instant(),
)?.let {
GatewayPrincipal(it.accountId, it.id, it.scopes)
}
}
private fun issue(refresh: StoredGatewayRefresh): GatewayGrantTokens {
val now = clock.instant()
val accessExpiresAt = minOf(now.plus(settings.accessTokenLifetime), refresh.grant.expiresAt)
require(accessExpiresAt.isAfter(now)) { "Gateway grant has expired" }
require(refresh.expiresAt.isAfter(now)) { "Gateway refresh token has expired" }
val claims = JWTClaimsSet.Builder()
.issuer(settings.issuer)
.audience(settings.audience)
.subject(refresh.grant.accountId)
.jwtID(UUID.randomUUID().toString())
.issueTime(Date.from(now))
.notBeforeTime(Date.from(now.minusSeconds(CLOCK_SKEW_SECONDS)))
.expirationTime(Date.from(accessExpiresAt))
.claim(CLAIM_TYPE, ACCESS_TOKEN_TYPE)
.claim(CLAIM_GRANT_ID, refresh.grant.id)
.claim(CLAIM_SCOPES, refresh.grant.scopes.map { it.name.lowercase() }.sorted())
.build()
val jwt = SignedJWT(JWSHeader(JWSAlgorithm.HS256), claims)
jwt.sign(MACSigner(settings.accessTokenHmacSecret))
return GatewayGrantTokens(
grantId = refresh.grant.id,
scopes = refresh.grant.scopes,
accessToken = jwt.serialize(),
accessExpiresAt = accessExpiresAt.toString(),
refreshToken = refreshToken(refresh.grant.id, refresh.familyId, refresh.tokenId),
refreshExpiresAt = refresh.expiresAt.toString(),
)
}
private fun verifyAccessToken(serialized: String): GatewayPrincipal? = runCatching {
val jwt = SignedJWT.parse(serialized)
require(jwt.header.algorithm == JWSAlgorithm.HS256)
require(jwt.verify(MACVerifier(settings.accessTokenHmacSecret)))
val claims = jwt.jwtClaimsSet
val now = clock.instant()
require(claims.issuer == settings.issuer)
require(settings.audience in claims.audience)
require(claims.getStringClaim(CLAIM_TYPE) == ACCESS_TOKEN_TYPE)
require(claims.expirationTime?.toInstant()?.isAfter(now.minusSeconds(CLOCK_SKEW_SECONDS)) == true)
require(claims.notBeforeTime?.toInstant()?.isBefore(now.plusSeconds(CLOCK_SKEW_SECONDS)) != false)
require(claims.issueTime?.toInstant()?.isAfter(now.plusSeconds(CLOCK_SKEW_SECONDS)) != true)
val scopes = claims.getStringListClaim(CLAIM_SCOPES)
.map { GatewayCapability.valueOf(it.uppercase()) }
.toSet()
require(scopes.isNotEmpty())
GatewayPrincipal(
userId = claims.subject,
grantId = UUID.fromString(claims.getStringClaim(CLAIM_GRANT_ID)).toString(),
scopes = scopes,
)
}.getOrNull()
private fun refreshToken(grantId: String, familyId: String, tokenId: String): String {
val publicPart = "$grantId.$familyId.$tokenId"
val mac = Mac.getInstance(HMAC_ALGORITHM)
mac.init(SecretKeySpec(settings.refreshTokenHmacSecret, HMAC_ALGORITHM))
val secret = Base64.getUrlEncoder().withoutPadding()
.encodeToString(mac.doFinal(publicPart.toByteArray(StandardCharsets.US_ASCII)))
return "$REFRESH_PREFIX$publicPart.$secret"
}
private fun parseRefreshToken(value: String): RefreshTokenParts {
if (!value.startsWith(REFRESH_PREFIX)) throw GatewayRefreshTokenInvalidException()
val parts = value.removePrefix(REFRESH_PREFIX).split('.')
if (parts.size != 4) throw GatewayRefreshTokenInvalidException()
val grantId = canonicalUuid(parts[0])
val familyId = canonicalUuid(parts[1])
canonicalUuid(parts[2])
val expected = refreshToken(grantId, familyId, parts[2])
if (!MessageDigest.isEqual(
expected.toByteArray(StandardCharsets.US_ASCII),
value.toByteArray(StandardCharsets.US_ASCII),
)
) {
throw GatewayRefreshTokenInvalidException()
}
return RefreshTokenParts(grantId, familyId)
}
private fun canonicalUuid(value: String): String =
runCatching { UUID.fromString(value).toString() }
.getOrElse { throw GatewayRefreshTokenInvalidException() }
private fun tokenHash(value: String): String =
MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(StandardCharsets.US_ASCII))
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
private fun validateIdempotencyKey(value: String) {
require(IDEMPOTENCY_KEY.matches(value)) { "Idempotency key is invalid" }
}
private data class RefreshTokenParts(val grantId: String, val familyId: String)
private companion object {
const val HMAC_ALGORITHM = "HmacSHA256"
const val CLAIM_TYPE = "typ"
const val CLAIM_GRANT_ID = "gid"
const val CLAIM_SCOPES = "scp"
const val ACCESS_TOKEN_TYPE = "gateway_access"
const val REFRESH_PREFIX = "gwrt_"
const val CLOCK_SKEW_SECONDS = 30L
const val MAX_REFRESH_TOKEN_CHARS = 512
val IDEMPOTENCY_KEY = Regex("[A-Za-z0-9._:-]{8,128}")
}
}
class GatewayBearerIdentity(
private val grants: GatewayGrantService,
) : GatewayAccessTokenPort {
override suspend fun resolve(call: ApplicationCall): GatewayPrincipal? {
val token = call.request.headers[HttpHeaders.Authorization]
?.takeIf { it.startsWith(BEARER_PREFIX, ignoreCase = true) }
?.substring(BEARER_PREFIX.length)
?.trim()
?.takeIf { it.isNotEmpty() && it.length <= MAX_ACCESS_TOKEN_CHARS }
?: return null
return grants.authenticate(token)
}
private companion object {
const val BEARER_PREFIX = "Bearer "
const val MAX_ACCESS_TOKEN_CHARS = 4_096
}
}
class GatewayRefreshTokenInvalidException : RuntimeException("Gateway refresh token is invalid")
class GatewayRefreshTokenReuseException : RuntimeException("Gateway refresh token reuse was detected")
@@ -0,0 +1,336 @@
package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.AsrProviderRequest
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewaySubject
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.CreditReservationPort
import com.osglab.account.features.gateway.ports.GatewayGrantPort
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.ports.ProviderUsageEstimate
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.time.TimeSource
class GatewayService(
private val catalog: ProviderCatalog,
private val credits: CreditReservationPort,
private val grants: GatewayGrantPort,
private val usageRecords: GatewayUsagePort,
private val usageEstimator: GatewayUsageEstimator = ConservativeGatewayUsageEstimator,
private val llmProviderTimeoutMillis: Long = 120_000L,
private val asrProviderTimeoutMillis: Long = 360_000L,
) {
init {
require(llmProviderTimeoutMillis > 0)
require(asrProviderTimeoutMillis > 0)
}
fun catalog() = catalog.descriptors()
suspend fun execute(
subject: GatewaySubject,
request: ProviderRequest,
output: ProviderOutput,
): ProviderUsage = executePrepared(prepare(subject, request), output)
suspend fun prepare(
subject: GatewaySubject,
request: ProviderRequest,
): PreparedGatewayRequest {
require(PROVIDER_REQUEST_ID.matches(request.requestId)) {
"Gateway request idempotency key is invalid"
}
if (request.capability !in subject.scopes) {
throw GatewayAccessDeniedException(request.capability)
}
if (!grants.isAllowed(subject.accountId, request.capability)) {
throw GatewayAccessDeniedException(request.capability)
}
val provider = catalog.providerFor(request)
val estimate = usageEstimator.estimate(request)
validateEstimate(request, estimate)
val reservation = credits.reserve(
accountId = subject.accountId,
estimate = estimate,
requestId = request.requestId,
)
try {
usageRecords.claim(
ProviderRequestMetadata(
requestId = request.requestId,
accountId = subject.accountId,
reservationId = reservation.id,
providerId = provider.descriptor.id,
capability = request.capability,
),
)
} catch (replay: GatewayRequestAlreadyClaimedException) {
// The existing claim owns the reservation. Releasing it here would
// refund an in-flight or completed request.
throw replay
} catch (failure: Throwable) {
releaseAfterFailure(reservation, failure)
throw failure
}
try {
usageRecords.markStarted(subject.accountId, request.requestId)
} catch (failure: Throwable) {
releaseAndRecord(
subject.accountId,
request.requestId,
reservation,
failure,
)
throw failure
}
return PreparedGatewayRequest(subject, request, provider, estimate, reservation)
}
suspend fun executePrepared(
prepared: PreparedGatewayRequest,
output: ProviderOutput,
): ProviderUsage {
val request = prepared.request
val provider = prepared.provider
val started = TimeSource.Monotonic.markNow()
val usage = try {
withTimeout(providerTimeoutMillis(request)) {
provider.execute(request, output)
}
} catch (failure: Throwable) {
releasePrepared(prepared, failure)
throw failure
}
return settlePrepared(
prepared,
usage.copy(serverDurationMillis = started.elapsedNow().inWholeMilliseconds.coerceAtLeast(1)),
)
}
suspend fun settlePrepared(
prepared: PreparedGatewayRequest,
usage: ProviderUsage,
): ProviderUsage {
val subject = prepared.subject
val request = prepared.request
val estimate = prepared.estimate
val reservation = prepared.reservation
try {
validateUsage(usage, estimate)
} catch (failure: Throwable) {
releasePrepared(prepared, failure)
throw failure
}
// Once upstream has completed, cancellation must not interrupt durable
// metering. The reservation remains frozen if any settlement step fails.
withContext(NonCancellable) {
val pendingRecorded = runCatching {
usageRecords.markSettlementPending(subject.accountId, request.requestId, usage)
}.isSuccess
val settled = runCatching { credits.settle(reservation.id, usage) }.isSuccess
if (settled && pendingRecorded) {
// Metadata failure after a successful settlement must not turn a
// successful provider response into a client-visible 502. The
// reconciliation job safely repeats the idempotent settlement.
runCatching {
usageRecords.markSucceeded(subject.accountId, request.requestId, usage)
}
} else if (!pendingRecorded) {
runCatching {
usageRecords.markManualReview(
subject.accountId,
request.requestId,
if (settled) "usage_record_pending" else "settlement_state_unavailable",
)
}
}
}
// Provider success is returned even while settlement is pending. Its
// reservation remains frozen and is never released by this path.
return usage
}
suspend fun releasePrepared(
prepared: PreparedGatewayRequest,
failure: Throwable,
) {
releaseAndRecord(
prepared.subject.accountId,
prepared.request.requestId,
prepared.reservation,
failure,
)
}
suspend fun markPreparedForReview(
prepared: PreparedGatewayRequest,
errorCode: String,
failure: Throwable,
) {
runCatching {
usageRecords.markManualReview(
prepared.subject.accountId,
prepared.request.requestId,
errorCode,
)
}.onFailure(failure::addSuppressed)
}
private suspend fun releaseAndRecord(
accountId: String,
requestId: String,
reservation: CreditReservation,
failure: Throwable,
): Unit = withContext(NonCancellable) {
val released = runCatching { credits.release(reservation.id) }
if (released.isSuccess) {
runCatching {
usageRecords.markReleased(
accountId,
requestId,
failure::class.simpleName ?: "provider_error",
)
}.onFailure(failure::addSuppressed)
} else {
released.exceptionOrNull()?.let(failure::addSuppressed)
runCatching {
usageRecords.markManualReview(accountId, requestId, "release_pending")
}.onFailure(failure::addSuppressed)
}
}
private fun validateUsage(usage: ProviderUsage, estimate: ProviderUsageEstimate) {
if (usage.meter != estimate.meter) {
throw GatewayUsagePolicyException("Provider usage meter differs from the reservation")
}
if (usage.units < 0 || usage.units > estimate.units) {
throw GatewayUsagePolicyException("Provider usage exceeds the reserved policy boundary")
}
when (usage.meter) {
UsageMeter.LLM_TOKEN -> {
val input = usage.inputUnits
?: throw GatewayUsagePolicyException("Provider omitted input token usage")
val output = usage.outputUnits
?: throw GatewayUsagePolicyException("Provider omitted output token usage")
if (input < 0 || output < 0) {
throw GatewayUsagePolicyException("Provider token usage cannot be negative")
}
val total = Math.addExact(input, output)
if (usage.units != total ||
input > requireNotNull(estimate.inputUnits) ||
output > requireNotNull(estimate.outputUnits)
) {
throw GatewayUsagePolicyException("Provider token usage is inconsistent")
}
}
UsageMeter.AUDIO_MILLISECOND -> Unit
}
}
private fun providerTimeoutMillis(request: ProviderRequest): Long =
when (request) {
is TextProviderRequest -> llmProviderTimeoutMillis
is AsrProviderRequest -> asrProviderTimeoutMillis
}
private fun validateEstimate(request: ProviderRequest, estimate: ProviderUsageEstimate) {
require(estimate.units > 0) { "Estimated usage must be positive" }
when (request) {
is TextProviderRequest -> {
require(estimate.meter == UsageMeter.LLM_TOKEN)
val input = requireNotNull(estimate.inputUnits)
val output = requireNotNull(estimate.outputUnits)
require(input >= 0 && output >= request.maxOutputTokens)
require(Math.addExact(input, output) == estimate.units)
}
is AsrProviderRequest -> {
require(estimate.meter == UsageMeter.AUDIO_MILLISECOND)
require(estimate.units >= request.options.estimatedDurationMillis)
require(estimate.inputUnits == null && estimate.outputUnits == null)
}
}
}
private suspend fun releaseAfterFailure(
reservation: CreditReservation,
failure: Throwable,
): Unit = withContext(NonCancellable) {
runCatching { credits.release(reservation.id) }
.onFailure(failure::addSuppressed)
}
private companion object {
val PROVIDER_REQUEST_ID = Regex("[A-Za-z0-9._:-]{8,64}")
}
}
data class PreparedGatewayRequest(
val subject: GatewaySubject,
val request: ProviderRequest,
val provider: GatewayProvider,
val estimate: ProviderUsageEstimate,
val reservation: CreditReservation,
)
class GatewayReconciliationService(
private val credits: CreditReservationPort,
private val usageRecords: GatewayUsagePort,
) {
suspend fun reconcile(limit: Int = 100): Int {
var completed = 0
usageRecords.findSettlementPending(limit).forEach { pending ->
try {
credits.settle(pending.reservationId, pending.usage)
usageRecords.markSucceeded(
pending.accountId,
pending.requestId,
pending.usage,
)
completed += 1
} catch (failure: CancellationException) {
throw failure
} catch (_: Exception) {
// Durable pending state is retried on the next pass.
}
}
return completed
}
}
/**
* Explicit recovery boundary for a settled provider call that must be fully
* reversed. The billing implementation owns idempotency and immutable ledger
* entries; normal provider failures use release before settlement instead.
*/
class GatewayRefundService(
private val billing: CreditReservationPort,
) {
suspend fun refund(reservationId: String) {
billing.refund(reservationId)
}
}
class GatewayUsagePolicyException(message: String) : RuntimeException(message)
class GatewayAccessDeniedException(capability: GatewayCapability) :
RuntimeException("Gateway grant does not allow ${capability.name.lowercase()}")
@@ -0,0 +1,46 @@
package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.AsrProviderRequest
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.ProviderUsageEstimate
/**
* Estimates the maximum metered usage before an upstream call. Actual usage is
* still supplied by the provider adapter and validated against this boundary.
*/
fun interface GatewayUsageEstimator {
fun estimate(request: ProviderRequest): ProviderUsageEstimate
}
object ConservativeGatewayUsageEstimator : GatewayUsageEstimator {
override fun estimate(request: ProviderRequest): ProviderUsageEstimate =
when (request) {
is TextProviderRequest -> estimateText(request)
is AsrProviderRequest -> ProviderUsageEstimate(
meter = UsageMeter.AUDIO_MILLISECOND,
units = request.options.estimatedDurationMillis,
)
}
private fun estimateText(request: TextProviderRequest): ProviderUsageEstimate {
// UTF-8 bytes are a conservative BPE upper bound. The fixed allowance
// covers server-controlled system messages and chat framing.
val inputBytes = request.input.encodeToByteArray().size.toLong()
val contextBytes = request.context?.encodeToByteArray()?.size?.toLong() ?: 0L
val input = Math.addExact(
Math.addExact(inputBytes, contextBytes),
LLM_PROMPT_OVERHEAD_TOKENS,
)
val output = request.maxOutputTokens.toLong()
return ProviderUsageEstimate(
meter = UsageMeter.LLM_TOKEN,
units = Math.addExact(input, output),
inputUnits = input,
outputUnits = output,
)
}
private const val LLM_PROMPT_OVERHEAD_TOKENS = 256L
}
@@ -0,0 +1,29 @@
package com.osglab.account.features.health
import com.osglab.account.config.DatabaseFactory
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
fun Route.healthRoutes(databaseFactory: DatabaseFactory) {
route("/health") {
get("/live") {
call.respond(HealthResponse(status = "UP"))
}
get("/ready") {
val databaseReady = databaseFactory.isReady()
if (databaseReady) {
call.respond(HealthResponse(status = "UP"))
} else {
call.respond(HttpStatusCode.ServiceUnavailable, HealthResponse(status = "DOWN"))
}
}
}
}
@Serializable
private data class HealthResponse(val status: String)
@@ -0,0 +1,674 @@
package com.osglab.account.features.integrity
import com.osglab.account.common.errors.ConflictException
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.config.IntegrityConfig
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.post
import io.ktor.server.routing.route
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.javatime.timestamp
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 java.security.MessageDigest
import java.security.SecureRandom
import java.time.Clock
import java.time.Instant
import java.util.Base64
import java.util.UUID
enum class AppAttestChallengePurpose {
ATTESTATION,
ASSERTION,
}
enum class AppAttestChallengeStatus {
ISSUED,
CONSUMED,
EXPIRED,
}
enum class AppAttestKeyStatus {
ACTIVE,
REVOKED,
}
data class AppAttestChallenge(
val id: UUID,
val keyId: String,
val purpose: AppAttestChallengePurpose,
/**
* Present only on issuance. Persistence stores [challengeHash], never the
* bearer challenge itself.
*/
val value: ByteArray,
val challengeHash: String,
val accountId: UUID?,
val status: AppAttestChallengeStatus,
val createdAt: Instant,
val expiresAt: Instant,
val consumedAt: Instant? = null,
)
data class StoredAppAttestKey(
val keyId: String,
val publicKey: ByteArray,
val receipt: ByteArray,
val counter: Long,
val accountId: UUID?,
val status: AppAttestKeyStatus = AppAttestKeyStatus.ACTIVE,
)
sealed interface ConsumedChallenge {
data object Valid : ConsumedChallenge
data object MissingOrMismatched : ConsumedChallenge
data object Expired : ConsumedChallenge
data object Replayed : ConsumedChallenge
}
interface AppAttestRepository {
suspend fun createChallenge(challenge: AppAttestChallenge)
/**
* Locks and consumes a challenge atomically. Repeating the same request
* deterministically returns [ConsumedChallenge.Replayed].
*/
suspend fun consumeChallenge(
id: UUID,
purpose: AppAttestChallengePurpose,
keyId: String,
challengeHash: String,
accountId: UUID?,
now: Instant,
): ConsumedChallenge
suspend fun saveKey(key: StoredAppAttestKey): Boolean
suspend fun findKey(keyId: String): StoredAppAttestKey?
suspend fun updateCounter(keyId: String, expectedCounter: Long, newCounter: Long, now: Instant): Boolean
suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant): Boolean
}
data class AttestedKeyMaterial(
val publicKey: ByteArray,
val receipt: ByteArray,
val initialCounter: Long,
)
interface AppAttestCrypto {
suspend fun validateAttestation(
attestationObject: ByteArray,
keyId: String,
challenge: ByteArray,
): AttestedKeyMaterial
/**
* [clientDataHash] is computed by the server from the operation payload.
* It must never be accepted as an arbitrary client-controlled identity.
*/
suspend fun validateAssertion(
assertionObject: ByteArray,
clientDataHash: ByteArray,
publicKey: ByteArray,
lastCounter: Long,
): Long
}
class AppAttestService(
private val repository: AppAttestRepository,
private val crypto: AppAttestCrypto,
private val config: IntegrityConfig,
private val clock: Clock = Clock.systemUTC(),
private val secureRandom: SecureRandom = SecureRandom(),
private val newId: () -> UUID = UUID::randomUUID,
) : AppAttestVerifier {
suspend fun issueChallenge(
purpose: AppAttestChallengePurpose,
keyId: String,
accountId: UUID? = null,
): AppAttestChallenge {
validateKeyId(keyId)
if (purpose == AppAttestChallengePurpose.ASSERTION) {
val key = repository.findKey(keyId)
?: throw InvalidRequestException("App Attest key is not registered")
if (key.status != AppAttestKeyStatus.ACTIVE) {
throw InvalidRequestException("App Attest key is not active")
}
if (accountId != null && key.accountId != null && key.accountId != accountId) {
throw InvalidRequestException("App Attest key is not associated with this account")
}
}
val now = clock.instant()
val value = ByteArray(CHALLENGE_BYTES).also(secureRandom::nextBytes)
val challenge = AppAttestChallenge(
id = newId(),
keyId = keyId,
purpose = purpose,
value = value,
challengeHash = sha256Hex(value),
accountId = accountId,
status = AppAttestChallengeStatus.ISSUED,
createdAt = now,
expiresAt = now.plusSeconds(config.challengeLifetimeSeconds),
)
repository.createChallenge(challenge)
return challenge
}
suspend fun attest(
challengeId: String,
challenge: String,
keyId: String,
attestationObject: String,
accountId: UUID? = null,
) {
validateKeyId(keyId)
val challengeBytes = decodeBase64Url(challenge, CHALLENGE_BYTES, "challenge")
consumeChallenge(
challengeId = challengeId,
purpose = AppAttestChallengePurpose.ATTESTATION,
keyId = keyId,
challenge = challengeBytes,
accountId = accountId,
)
val material = validateAttestation(attestationObject, keyId, challengeBytes)
val stored = StoredAppAttestKey(
keyId = keyId,
publicKey = material.publicKey,
receipt = material.receipt,
counter = material.initialCounter,
accountId = accountId,
)
if (!repository.saveKey(stored)) {
throw ConflictException("App Attest key is already registered")
}
}
/**
* Verifies a challenge-only assertion used by the public integrity route.
* Business operations should use [verifyBoundAssertion] with their own
* server-canonical payload hash.
*/
suspend fun assertChallenge(
challengeId: String,
challenge: String,
keyId: String,
assertionObject: String,
clientDataHash: String,
): Long {
val challengeBytes = decodeBase64Url(challenge, CHALLENGE_BYTES, "challenge")
val suppliedHash = decodeBase64Url(clientDataHash, SHA256_BYTES, "clientDataHash")
val expectedHash = sha256(challengeBytes)
if (!MessageDigest.isEqual(suppliedHash, expectedHash)) {
throw InvalidRequestException("clientDataHash is not bound to the challenge")
}
return verifyBoundAssertion(
challengeId = challengeId,
challenge = challengeBytes,
keyId = keyId,
assertionObject = assertionObject,
expectedClientDataHash = expectedHash,
)
}
suspend fun verifyBoundAssertion(
challengeId: String,
challenge: ByteArray,
keyId: String,
assertionObject: String,
expectedClientDataHash: ByteArray,
expectedAccountId: UUID? = null,
): Long {
validateKeyId(keyId)
require(expectedClientDataHash.size == SHA256_BYTES) {
"Server clientDataHash must contain 32 bytes"
}
consumeChallenge(
challengeId = challengeId,
purpose = AppAttestChallengePurpose.ASSERTION,
keyId = keyId,
challenge = challenge,
accountId = expectedAccountId,
)
val key = repository.findKey(keyId)
?: throw AppAttestRejectedException("App Attest key is not registered")
if (key.status != AppAttestKeyStatus.ACTIVE) {
throw AppAttestRejectedException("App Attest key is not active")
}
if (expectedAccountId != null && key.accountId != expectedAccountId) {
throw AppAttestRejectedException("App Attest key is not associated with this account")
}
val newCounter = validateAssertion(
assertionObject = assertionObject,
clientDataHash = expectedClientDataHash,
key = key,
)
if (!repository.updateCounter(
keyId,
expectedCounter = key.counter,
newCounter = newCounter,
now = clock.instant(),
)
) {
throw AppAttestRejectedException("App Attest counter did not advance atomically")
}
return newCounter
}
override suspend fun verify(
evidence: AppAttestEvidence,
payload: AppleSignInIntegrityPayload,
): IntegrityVerification = try {
val challenge = evidence.challenge
?.let { decodeBase64Url(it, CHALLENGE_BYTES, "challenge") }
?: throw AppAttestRejectedException("App Attest challenge is missing")
val canonical = AppAttestCanonicalPayload.appleSignIn(challenge, payload)
verifyBoundAssertion(
challengeId = evidence.challengeId,
challenge = challenge,
keyId = evidence.keyId,
assertionObject = evidence.assertion,
expectedClientDataHash = sha256(canonical),
)
IntegrityVerification.Verified
} catch (exception: AppAttestRejectedException) {
IntegrityVerification.Rejected(exception.message ?: "App Attest rejected the assertion")
} catch (exception: InvalidRequestException) {
IntegrityVerification.Rejected(exception.message)
} catch (exception: AppAttestUnavailableException) {
IntegrityVerification.Unavailable(exception.message ?: "App Attest verification is unavailable")
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
IntegrityVerification.Unavailable("App Attest verification is unavailable")
}
override suspend fun bindKeyToAccount(keyId: String, accountId: UUID) {
if (!repository.bindKeyToAccount(keyId, accountId, clock.instant())) {
throw InvalidRequestException("App Attest key belongs to another account")
}
}
private suspend fun validateAttestation(
attestationObject: String,
keyId: String,
challenge: ByteArray,
): AttestedKeyMaterial = try {
crypto.validateAttestation(
decodeBase64(attestationObject, MAX_ATTESTATION_BYTES, "attestationObject"),
keyId,
challenge,
)
} catch (exception: AppAttestUnavailableException) {
throw exception
} catch (exception: AppAttestRejectedException) {
throw InvalidRequestException("App Attest attestation failed")
}
private suspend fun validateAssertion(
assertionObject: String,
clientDataHash: ByteArray,
key: StoredAppAttestKey,
): Long = try {
crypto.validateAssertion(
assertionObject = decodeBase64(assertionObject, MAX_ASSERTION_BYTES, "assertion"),
clientDataHash = clientDataHash,
publicKey = key.publicKey,
lastCounter = key.counter,
)
} catch (exception: AppAttestUnavailableException) {
throw exception
} catch (exception: AppAttestRejectedException) {
throw exception
}
private suspend fun consumeChallenge(
challengeId: String,
purpose: AppAttestChallengePurpose,
keyId: String,
challenge: ByteArray,
accountId: UUID? = null,
) {
val id = runCatching { UUID.fromString(challengeId) }
.getOrElse { throw InvalidRequestException("App Attest challenge is invalid") }
when (
repository.consumeChallenge(
id = id,
purpose = purpose,
keyId = keyId,
challengeHash = sha256Hex(challenge),
accountId = accountId,
now = clock.instant(),
)
) {
ConsumedChallenge.Valid -> Unit
ConsumedChallenge.Expired ->
throw AppAttestRejectedException("App Attest challenge has expired")
ConsumedChallenge.Replayed ->
throw AppAttestRejectedException("App Attest challenge was already used")
ConsumedChallenge.MissingOrMismatched ->
throw AppAttestRejectedException("App Attest challenge is invalid")
}
}
private fun validateKeyId(keyId: String) {
val decoded = runCatching { Base64.getDecoder().decode(keyId) }
.getOrElse { throw InvalidRequestException("App Attest keyId must be Base64") }
if (decoded.size != APPLE_KEY_ID_BYTES) {
throw InvalidRequestException("App Attest keyId must encode 32 bytes")
}
}
private fun decodeBase64(value: String, maxBytes: Int, field: String): ByteArray {
val decoded = runCatching { Base64.getDecoder().decode(value) }
.getOrElse { throw AppAttestRejectedException("$field must be Base64") }
if (decoded.isEmpty() || decoded.size > maxBytes) {
throw AppAttestRejectedException("$field size is invalid")
}
return decoded
}
private fun decodeBase64Url(value: String, exactBytes: Int, field: String): ByteArray {
val decoded = runCatching { BASE64_URL_DECODER.decode(value) }
.getOrElse { throw InvalidRequestException("$field must be Base64URL") }
if (decoded.size != exactBytes) throw InvalidRequestException("$field size is invalid")
return decoded
}
private companion object {
const val CHALLENGE_BYTES = 32
const val APPLE_KEY_ID_BYTES = 32
const val SHA256_BYTES = 32
const val MAX_ATTESTATION_BYTES = 256 * 1024
const val MAX_ASSERTION_BYTES = 64 * 1024
}
}
object AppAttestCanonicalPayload {
fun appleSignIn(
challenge: ByteArray,
payload: AppleSignInIntegrityPayload,
): ByteArray = buildString {
appendLine("osg-app-attest-v1")
appendLine("purpose=apple-sign-in")
appendLine("challenge=${BASE64_URL.encodeToString(challenge)}")
appendLine("identity_token_sha256=${digest(payload.identityToken)}")
appendLine("authorization_code_sha256=${digest(payload.authorizationCode)}")
appendLine("nonce_sha256=${digest(payload.nonce)}")
}.toByteArray(Charsets.UTF_8)
private fun digest(value: String): String = BASE64_URL.encodeToString(
sha256(value.toByteArray(Charsets.UTF_8)),
)
}
class AppAttestRejectedException(message: String, cause: Throwable? = null) :
IllegalArgumentException(message, cause)
class AppAttestUnavailableException(message: String, cause: Throwable? = null) :
IllegalStateException(message, cause)
private object AppAttestChallenges : Table("app_attest_challenges") {
val id = varchar("id", 36)
val keyId = varchar("key_id", 128)
val accountId = varchar("account_id", 36).nullable()
val purpose = enumerationByName<AppAttestChallengePurpose>("purpose", 16)
val challengeHash = char("challenge_hash", 64)
val status = enumerationByName<AppAttestChallengeStatus>("status", 16)
val expiresAt = timestamp("expires_at")
val consumedAt = timestamp("consumed_at").nullable()
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object AppAttestKeys : Table("app_attest_keys") {
val keyId = varchar("key_id", 128)
val publicKey = varchar("public_key_base64", 512)
val receipt = text("receipt_base64")
val signCounter = long("sign_counter")
val accountId = varchar("account_id", 36).nullable()
val status = enumerationByName<AppAttestKeyStatus>("status", 16)
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(keyId)
}
class ExposedAppAttestRepository(
private val databaseFactory: DatabaseFactory,
private val clock: Clock = Clock.systemUTC(),
) : AppAttestRepository {
override suspend fun createChallenge(challenge: AppAttestChallenge) {
databaseFactory.query {
AppAttestChallenges.insert {
it[id] = challenge.id.toString()
it[keyId] = challenge.keyId
it[accountId] = challenge.accountId?.toString()
it[purpose] = challenge.purpose
it[challengeHash] = challenge.challengeHash
it[status] = challenge.status
it[expiresAt] = challenge.expiresAt
it[consumedAt] = challenge.consumedAt
it[createdAt] = challenge.createdAt
}
}
}
override suspend fun consumeChallenge(
id: UUID,
purpose: AppAttestChallengePurpose,
keyId: String,
challengeHash: String,
accountId: UUID?,
now: Instant,
): ConsumedChallenge = databaseFactory.query {
val row = AppAttestChallenges.selectAll()
.where { AppAttestChallenges.id eq id.toString() }
.forUpdate()
.singleOrNull()
?: return@query ConsumedChallenge.MissingOrMismatched
if (row[AppAttestChallenges.purpose] != purpose ||
row[AppAttestChallenges.keyId] != keyId ||
(accountId != null && row[AppAttestChallenges.accountId] != accountId.toString()) ||
!constantTimeHexEquals(row[AppAttestChallenges.challengeHash], challengeHash)
) {
return@query ConsumedChallenge.MissingOrMismatched
}
if (row[AppAttestChallenges.status] == AppAttestChallengeStatus.CONSUMED) {
return@query ConsumedChallenge.Replayed
}
if (!row[AppAttestChallenges.expiresAt].isAfter(now)) {
AppAttestChallenges.update({ AppAttestChallenges.id eq id.toString() }) {
it[status] = AppAttestChallengeStatus.EXPIRED
}
return@query ConsumedChallenge.Expired
}
if (row[AppAttestChallenges.status] != AppAttestChallengeStatus.ISSUED) {
return@query ConsumedChallenge.Expired
}
AppAttestChallenges.update({ AppAttestChallenges.id eq id.toString() }) {
it[status] = AppAttestChallengeStatus.CONSUMED
it[consumedAt] = now
}
ConsumedChallenge.Valid
}
override suspend fun saveKey(key: StoredAppAttestKey): Boolean = databaseFactory.query {
val now = clock.instant()
AppAttestKeys.insertIgnore {
it[keyId] = key.keyId
it[publicKey] = Base64.getEncoder().encodeToString(key.publicKey)
it[receipt] = Base64.getEncoder().encodeToString(key.receipt)
it[signCounter] = key.counter
it[accountId] = key.accountId?.toString()
it[status] = key.status
it[createdAt] = now
it[updatedAt] = now
}.insertedCount == 1
}
override suspend fun findKey(keyId: String): StoredAppAttestKey? = databaseFactory.query {
AppAttestKeys.selectAll()
.where { AppAttestKeys.keyId eq keyId }
.singleOrNull()
?.toStoredAppAttestKey()
}
override suspend fun updateCounter(
keyId: String,
expectedCounter: Long,
newCounter: Long,
now: Instant,
): Boolean {
if (newCounter <= expectedCounter) return false
return databaseFactory.query {
AppAttestKeys.update({
(AppAttestKeys.keyId eq keyId) and
(AppAttestKeys.signCounter eq expectedCounter) and
(AppAttestKeys.status eq AppAttestKeyStatus.ACTIVE)
}) {
it[signCounter] = newCounter
it[updatedAt] = now
} == 1
}
}
override suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant): Boolean =
databaseFactory.query {
val row = AppAttestKeys.selectAll()
.where { AppAttestKeys.keyId eq keyId }
.forUpdate()
.singleOrNull()
?: return@query false
if (row[AppAttestKeys.status] != AppAttestKeyStatus.ACTIVE) return@query false
val existing = row[AppAttestKeys.accountId]
if (existing != null) return@query existing == accountId.toString()
AppAttestKeys.update({ AppAttestKeys.keyId eq keyId }) {
it[AppAttestKeys.accountId] = accountId.toString()
it[updatedAt] = now
}
true
}
}
@Serializable
data class AppAttestChallengeRequest(
val purpose: String,
val keyId: String,
)
@Serializable
data class AppAttestChallengeResponse(
val challengeId: String,
val challenge: String,
val expiresAtEpochSeconds: Long,
)
@Serializable
data class AppAttestationRequest(
val challengeId: String,
val challenge: String,
val keyId: String,
val attestationObject: String,
)
@Serializable
data class AppAssertionRequest(
val challengeId: String,
val challenge: String,
val keyId: String,
val assertion: String,
val clientDataHash: String,
)
@Serializable
data class AppAssertionResponse(val counter: Long)
fun Route.integrityRoutes(service: AppAttestService) {
route("/v1/integrity") {
post("/challenges") {
val request = call.receive<AppAttestChallengeRequest>()
val purpose = runCatching {
AppAttestChallengePurpose.valueOf(request.purpose.trim().uppercase())
}.getOrElse {
throw InvalidRequestException("purpose must be attestation or assertion")
}
val challenge = service.issueChallenge(purpose, request.keyId)
call.respond(
HttpStatusCode.Created,
AppAttestChallengeResponse(
challengeId = challenge.id.toString(),
challenge = BASE64_URL.encodeToString(challenge.value),
expiresAtEpochSeconds = challenge.expiresAt.epochSecond,
),
)
}
post("/attest") {
val request = call.receive<AppAttestationRequest>()
try {
service.attest(
request.challengeId,
request.challenge,
request.keyId,
request.attestationObject,
)
} catch (_: AppAttestRejectedException) {
throw InvalidRequestException("App Attest attestation failed")
} catch (_: AppAttestUnavailableException) {
throw ExternalServiceUnavailableException("App Attest")
}
call.respond(HttpStatusCode.NoContent)
}
post("/assert") {
val request = call.receive<AppAssertionRequest>()
val counter = try {
service.assertChallenge(
request.challengeId,
request.challenge,
request.keyId,
request.assertion,
request.clientDataHash,
)
} catch (_: AppAttestRejectedException) {
throw InvalidRequestException("App Attest assertion failed")
} catch (_: AppAttestUnavailableException) {
throw ExternalServiceUnavailableException("App Attest")
}
call.respond(AppAssertionResponse(counter))
}
}
}
private fun ResultRow.toStoredAppAttestKey() = StoredAppAttestKey(
keyId = this[AppAttestKeys.keyId],
publicKey = Base64.getDecoder().decode(this[AppAttestKeys.publicKey]),
receipt = Base64.getDecoder().decode(this[AppAttestKeys.receipt]),
counter = this[AppAttestKeys.signCounter],
accountId = this[AppAttestKeys.accountId]?.let(UUID::fromString),
status = this[AppAttestKeys.status],
)
private fun sha256(value: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(value)
private fun sha256Hex(value: ByteArray): String =
sha256(value).joinToString("") { "%02x".format(it) }
private fun constantTimeHexEquals(left: String, right: String): Boolean =
MessageDigest.isEqual(
left.toByteArray(Charsets.US_ASCII),
right.toByteArray(Charsets.US_ASCII),
)
private val BASE64_URL: Base64.Encoder = Base64.getUrlEncoder().withoutPadding()
private val BASE64_URL_DECODER: Base64.Decoder = Base64.getUrlDecoder()
@@ -0,0 +1,491 @@
package com.osglab.account.features.integrity
import com.osglab.account.config.AppleServiceEnvironment
import com.osglab.account.config.IntegrityConfig
import com.upokecenter.cbor.CBORObject
import org.bouncycastle.asn1.ASN1OctetString
import org.bouncycastle.asn1.ASN1Primitive
import org.bouncycastle.asn1.ASN1Sequence
import org.bouncycastle.asn1.ASN1TaggedObject
import java.io.ByteArrayInputStream
import java.math.BigInteger
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.MessageDigest
import java.security.Signature
import java.security.cert.CertPathValidator
import java.security.cert.CertificateFactory
import java.security.cert.PKIXParameters
import java.security.cert.TrustAnchor
import java.security.cert.X509Certificate
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.ECParameterSpec
import java.security.spec.ECPoint
import java.security.spec.ECPublicKeySpec
import java.security.spec.X509EncodedKeySpec
import java.time.Clock
import java.util.Base64
import java.util.Date
/**
* Certificate verification is an explicit external boundary. Production DI
* must inject Apple App Attest root certificates obtained out-of-band.
*/
fun interface AppAttestCertificateValidator {
fun validateAndReadNonce(certificateChain: List<ByteArray>): ValidatedAppAttestCertificate
}
data class ValidatedAppAttestCertificate(
val publicKey: ECPublicKey,
val nonce: ByteArray,
)
class PkixAppAttestCertificateValidator(
appleRoots: Collection<X509Certificate>,
private val clock: Clock = Clock.systemUTC(),
) : AppAttestCertificateValidator {
private val roots: Set<TrustAnchor> = appleRoots
.map { certificate -> TrustAnchor(certificate, null) }
.toSet()
init {
require(roots.isNotEmpty()) {
"At least one Apple App Attest root certificate must be configured"
}
}
override fun validateAndReadNonce(
certificateChain: List<ByteArray>,
): ValidatedAppAttestCertificate {
if (certificateChain.size !in 2..4) {
throw AppAttestRejectedException("App Attest x5c chain length is invalid")
}
val certificates = certificateChain.map(::decodeCertificate)
if (certificates.first().basicConstraints >= 0) {
throw AppAttestRejectedException("App Attest leaf certificate is not an end-entity certificate")
}
val pathCertificates = certificates.dropLastWhile { candidate ->
roots.any { root ->
candidate.subjectX500Principal == root.trustedCert.subjectX500Principal &&
candidate.publicKey == root.trustedCert.publicKey
}
}
if (pathCertificates.isEmpty()) {
throw AppAttestRejectedException("App Attest x5c chain does not contain a leaf certificate")
}
try {
val certPath = CertificateFactory.getInstance("X.509").generateCertPath(pathCertificates)
CertPathValidator.getInstance("PKIX").validate(
certPath,
PKIXParameters(roots).apply {
isRevocationEnabled = false
date = Date.from(clock.instant())
},
)
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest certificate chain is not trusted", exception)
}
val leaf = pathCertificates.first()
val publicKey = leaf.publicKey as? ECPublicKey
?: throw AppAttestRejectedException("App Attest certificate key is not EC")
return ValidatedAppAttestCertificate(
publicKey = publicKey,
nonce = readAppleNonce(leaf),
)
}
private fun decodeCertificate(encoded: ByteArray): X509Certificate =
try {
CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(encoded)) as X509Certificate
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest x5c contains an invalid certificate", exception)
}
private fun readAppleNonce(certificate: X509Certificate): ByteArray {
val wrapped = certificate.getExtensionValue(APPLE_NONCE_EXTENSION_OID)
?: throw AppAttestRejectedException("App Attest certificate nonce extension is missing")
return try {
val extension = ASN1OctetString.getInstance(ASN1Primitive.fromByteArray(wrapped)).octets
val sequence = ASN1Sequence.getInstance(ASN1Primitive.fromByteArray(extension))
if (sequence.size() != 1) {
throw AppAttestRejectedException("App Attest certificate nonce extension is malformed")
}
val tagged = ASN1TaggedObject.getInstance(sequence.getObjectAt(0))
ASN1OctetString.getInstance(tagged, true).octets.also {
if (it.size != SHA256_BYTES) {
throw AppAttestRejectedException("App Attest certificate nonce size is invalid")
}
}
} catch (exception: AppAttestRejectedException) {
throw exception
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest certificate nonce extension is malformed", exception)
}
}
private companion object {
const val APPLE_NONCE_EXTENSION_OID = "1.2.840.113635.100.8.2"
const val SHA256_BYTES = 32
}
}
object BundledAppleAppAttestTrust {
private const val ROOT_RESOURCE = "/apple/Apple_App_Attestation_Root_CA.pem"
fun validator(): AppAttestCertificateValidator =
PkixAppAttestCertificateValidator(listOf(loadRootCertificate()))
internal fun loadRootCertificate(): X509Certificate {
val stream = BundledAppleAppAttestTrust::class.java.getResourceAsStream(ROOT_RESOURCE)
?: throw AppAttestUnavailableException(
"Bundled Apple App Attestation root certificate is missing",
)
return try {
stream.use {
CertificateFactory.getInstance("X.509").generateCertificate(it) as X509Certificate
}
} catch (exception: Exception) {
throw AppAttestUnavailableException(
"Bundled Apple App Attestation root certificate is invalid",
exception,
)
}
}
}
/**
* Strict production verifier for Apple App Attest CBOR artifacts.
*
* Application DI supplies the pinned Apple trust anchor through
* [AppAttestCertificateValidator].
*/
class LibraryAppAttestCrypto(
config: IntegrityConfig,
private val certificateValidator: AppAttestCertificateValidator,
) : AppAttestCrypto {
private val rpIdHash = sha256(
"${config.appAttestTeamId}.${config.appAttestBundleId}".toByteArray(Charsets.UTF_8),
)
private val expectedAaguid = when (config.appleEnvironment) {
AppleServiceEnvironment.DEVELOPMENT -> DEVELOPMENT_AAGUID
AppleServiceEnvironment.PRODUCTION -> PRODUCTION_AAGUID
}
override suspend fun validateAttestation(
attestationObject: ByteArray,
keyId: String,
challenge: ByteArray,
): AttestedKeyMaterial = rejectMalformed("attestation") {
val attestation = decodeMap(attestationObject, "attestationObject")
if (attestation.requiredText("fmt") != APPLE_ATTESTATION_FORMAT) {
throw AppAttestRejectedException("App Attest format is invalid")
}
val statement = attestation.requiredMap("attStmt")
val chain = statement.requiredArray("x5c").values.map { item ->
item.asByteString("x5c certificate")
}
val receipt = statement.requiredBytes("receipt")
if (receipt.isEmpty()) throw AppAttestRejectedException("App Attest receipt is empty")
val authenticatorDataBytes = attestation.requiredBytes("authData")
val authenticatorData = parseAttestationAuthenticatorData(authenticatorDataBytes)
requireRpId(authenticatorData.rpIdHash)
if (authenticatorData.signCount != 0L) {
throw AppAttestRejectedException("App Attest attestation counter must start at zero")
}
if (!MessageDigest.isEqual(authenticatorData.aaguid, expectedAaguid)) {
throw AppAttestRejectedException("App Attest AAGUID does not match the configured environment")
}
val decodedKeyId = decodeKeyId(keyId)
if (!MessageDigest.isEqual(authenticatorData.credentialId, decodedKeyId)) {
throw AppAttestRejectedException("App Attest credentialId does not match keyId")
}
val cosePublicKey = decodeCosePublicKey(authenticatorData.coseKey)
if (!MessageDigest.isEqual(sha256(uncompressedPoint(cosePublicKey)), decodedKeyId)) {
throw AppAttestRejectedException("App Attest keyId does not identify the credential public key")
}
val validatedCertificate = certificateValidator.validateAndReadNonce(chain)
if (!sameEcPoint(validatedCertificate.publicKey, cosePublicKey)) {
throw AppAttestRejectedException("App Attest certificate and credential keys differ")
}
val clientDataHash = sha256(challenge)
val expectedNonce = sha256(authenticatorDataBytes + clientDataHash)
if (!MessageDigest.isEqual(validatedCertificate.nonce, expectedNonce)) {
throw AppAttestRejectedException("App Attest certificate nonce is invalid")
}
AttestedKeyMaterial(
publicKey = cosePublicKey.encoded,
receipt = receipt,
initialCounter = authenticatorData.signCount,
)
}
override suspend fun validateAssertion(
assertionObject: ByteArray,
clientDataHash: ByteArray,
publicKey: ByteArray,
lastCounter: Long,
): Long = rejectMalformed("assertion") {
if (clientDataHash.size != SHA256_BYTES) {
throw AppAttestRejectedException("App Attest clientDataHash size is invalid")
}
if (lastCounter < 0) {
throw AppAttestRejectedException("App Attest stored counter is invalid")
}
val assertion = decodeMap(assertionObject, "assertionObject")
val authenticatorDataBytes = assertion.requiredBytes("authenticatorData")
val signatureBytes = assertion.requiredBytes("signature")
val authenticatorData = parseAssertionAuthenticatorData(authenticatorDataBytes)
requireRpId(authenticatorData.rpIdHash)
if (authenticatorData.signCount <= lastCounter) {
throw AppAttestRejectedException("App Attest assertion counter did not increase")
}
val key = try {
KeyFactory.getInstance("EC")
.generatePublic(X509EncodedKeySpec(publicKey)) as ECPublicKey
} catch (exception: Exception) {
throw AppAttestUnavailableException("Stored App Attest public key is invalid", exception)
}
val signedBytes = authenticatorDataBytes + clientDataHash
val verified = try {
Signature.getInstance("SHA256withECDSA").run {
initVerify(key)
update(signedBytes)
verify(signatureBytes)
}
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest assertion signature is malformed", exception)
}
if (!verified) throw AppAttestRejectedException("App Attest assertion signature is invalid")
authenticatorData.signCount
}
private fun parseAttestationAuthenticatorData(bytes: ByteArray): AttestationAuthenticatorData {
if (bytes.size < MIN_ATTESTATION_AUTH_DATA_BYTES) {
throw AppAttestRejectedException("App Attest authenticatorData is truncated")
}
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN)
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) {
throw AppAttestRejectedException("App Attest attested credential flag is missing")
}
val aaguid = ByteArray(AAGUID_BYTES).also(buffer::get)
val credentialLength = buffer.short.toInt() and UINT16_MASK
if (credentialLength == 0 || credentialLength > buffer.remaining()) {
throw AppAttestRejectedException("App Attest credentialId length is invalid")
}
val credentialId = ByteArray(credentialLength).also(buffer::get)
if (!buffer.hasRemaining()) {
throw AppAttestRejectedException("App Attest COSE key is missing")
}
val coseKey = ByteArray(buffer.remaining()).also(buffer::get)
return AttestationAuthenticatorData(rpHash, flags, count, aaguid, credentialId, coseKey)
}
private fun parseAssertionAuthenticatorData(bytes: ByteArray): AssertionAuthenticatorData {
if (bytes.size != ASSERTION_AUTH_DATA_BYTES) {
// App Attest assertions currently contain only RP hash, flags and counter.
// Strictly reject unknown extensions until Apple documents server handling.
throw AppAttestRejectedException("App Attest assertion authenticatorData length is invalid")
}
val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN)
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
) {
throw AppAttestRejectedException("App Attest assertion flags are invalid")
}
return AssertionAuthenticatorData(rpHash, flags, count)
}
private fun decodeCosePublicKey(encoded: ByteArray): ECPublicKey {
val cose = decodeMap(encoded, "credential public key")
if (cose.requiredInt(1) != COSE_EC2_KEY_TYPE ||
cose.requiredInt(3) != COSE_ES256_ALGORITHM ||
cose.requiredInt(-1) != COSE_P256_CURVE
) {
throw AppAttestRejectedException("App Attest credential public key parameters are invalid")
}
val x = cose.requiredBytes(-2)
val y = cose.requiredBytes(-3)
if (x.size != P256_COORDINATE_BYTES || y.size != P256_COORDINATE_BYTES) {
throw AppAttestRejectedException("App Attest credential public key size is invalid")
}
return try {
KeyFactory.getInstance("EC").generatePublic(
ECPublicKeySpec(
ECPoint(BigInteger(1, x), BigInteger(1, y)),
P256_PARAMETERS,
),
) as ECPublicKey
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest credential public key is invalid", exception)
}
}
private fun requireRpId(actual: ByteArray) {
if (!MessageDigest.isEqual(actual, rpIdHash)) {
throw AppAttestRejectedException("App Attest RP ID hash is invalid")
}
}
private data class AttestationAuthenticatorData(
val rpIdHash: ByteArray,
val flags: Int,
val signCount: Long,
val aaguid: ByteArray,
val credentialId: ByteArray,
val coseKey: ByteArray,
)
private data class AssertionAuthenticatorData(
val rpIdHash: ByteArray,
val flags: Int,
val signCount: Long,
)
private companion object {
const val APPLE_ATTESTATION_FORMAT = "apple-appattest"
const val SHA256_BYTES = 32
const val AAGUID_BYTES = 16
const val P256_COORDINATE_BYTES = 32
const val ASSERTION_AUTH_DATA_BYTES = 37
const val MIN_ATTESTATION_AUTH_DATA_BYTES = 55
const val FLAG_ATTESTED_CREDENTIAL_DATA = 0x40
const val FLAG_EXTENSION_DATA = 0x80
const val COSE_EC2_KEY_TYPE = 2
const val COSE_ES256_ALGORITHM = -7
const val COSE_P256_CURVE = 1
const val UINT16_MASK = 0xffff
const val UINT32_MASK = 0xffff_ffffL
val DEVELOPMENT_AAGUID: ByteArray = "appattestdevelop".toByteArray(Charsets.US_ASCII)
val PRODUCTION_AAGUID: ByteArray =
"appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7)
val P256_PARAMETERS: ECParameterSpec = AlgorithmParameters.getInstance("EC").run {
init(ECGenParameterSpec("secp256r1"))
getParameterSpec(ECParameterSpec::class.java)
}
}
}
private fun decodeMap(encoded: ByteArray, label: String): CBORObject {
val value = try {
CBORObject.DecodeFromBytes(encoded)
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest $label is invalid CBOR", exception)
}
if (value.type != com.upokecenter.cbor.CBORType.Map) {
throw AppAttestRejectedException("App Attest $label must be a CBOR map")
}
return value
}
private fun CBORObject.requiredMap(key: String): CBORObject =
required(key).also {
if (it.type != com.upokecenter.cbor.CBORType.Map) {
throw AppAttestRejectedException("App Attest $key must be a CBOR map")
}
}
private fun CBORObject.requiredArray(key: String): CBORObject =
required(key).also {
if (it.type != com.upokecenter.cbor.CBORType.Array) {
throw AppAttestRejectedException("App Attest $key must be a CBOR array")
}
}
private fun CBORObject.requiredText(key: String): String {
val value = required(key)
if (value.type != com.upokecenter.cbor.CBORType.TextString) {
throw AppAttestRejectedException("App Attest $key must be text")
}
return value.AsString()
}
private fun CBORObject.requiredBytes(key: String): ByteArray =
required(key).asByteString(key)
private fun CBORObject.requiredBytes(key: Int): ByteArray =
required(key).asByteString(key.toString())
private fun CBORObject.asByteString(label: String): ByteArray {
if (type != com.upokecenter.cbor.CBORType.ByteString) {
throw AppAttestRejectedException("App Attest $label must be bytes")
}
return GetByteString()
}
private fun CBORObject.requiredInt(key: Int): Int {
val value = required(key)
if (!value.isNumber || !value.AsNumber().IsInteger()) {
throw AppAttestRejectedException("App Attest COSE parameter $key must be an integer")
}
return try {
value.AsInt32()
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest COSE parameter $key is out of range", exception)
}
}
private fun CBORObject.required(key: String): CBORObject =
this[CBORObject.FromObject(key)]
?: throw AppAttestRejectedException("App Attest CBOR field $key is missing")
private fun CBORObject.required(key: Int): CBORObject =
this[CBORObject.FromObject(key)]
?: throw AppAttestRejectedException("App Attest COSE parameter $key is missing")
private inline fun <T> rejectMalformed(label: String, block: () -> T): T =
try {
block()
} catch (exception: AppAttestRejectedException) {
throw exception
} catch (exception: AppAttestUnavailableException) {
throw exception
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest $label is malformed", exception)
}
private fun decodeKeyId(keyId: String): ByteArray =
try {
Base64.getDecoder().decode(keyId).also {
if (it.size != 32) throw AppAttestRejectedException("App Attest keyId size is invalid")
}
} catch (exception: AppAttestRejectedException) {
throw exception
} catch (exception: Exception) {
throw AppAttestRejectedException("App Attest keyId is invalid", exception)
}
private fun sameEcPoint(left: ECPublicKey, right: ECPublicKey): Boolean =
MessageDigest.isEqual(uncompressedPoint(left), uncompressedPoint(right))
private fun uncompressedPoint(key: ECPublicKey): ByteArray =
byteArrayOf(0x04) +
key.w.affineX.toUnsignedFixed(32) +
key.w.affineY.toUnsignedFixed(32)
private fun BigInteger.toUnsignedFixed(size: Int): ByteArray {
val raw = toByteArray()
val unsigned = if (raw.size == size + 1 && raw.first() == 0.toByte()) {
raw.copyOfRange(1, raw.size)
} else {
raw
}
if (unsigned.size > size) {
throw AppAttestRejectedException("App Attest EC coordinate is too large")
}
return ByteArray(size - unsigned.size) + unsigned
}
private fun sha256(value: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(value)
@@ -0,0 +1,499 @@
package com.osglab.account.features.integrity
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.ECDSASigner
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.config.AppleConfig
import com.osglab.account.config.AppleServiceEnvironment
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.config.IntegrityPolicy
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpRequestTimeoutException
import io.ktor.client.plugins.timeout
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.coroutines.CancellationException
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.eq
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 java.security.KeyFactory
import java.security.interfaces.ECPrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.Date
import java.util.UUID
data class DeviceCheckState(
val bit0: Boolean,
val bit1: Boolean,
val lastUpdateTime: String?,
)
/**
* Apple persists two bits per physical device:
* - bit0: this device has consumed the one-time signup trial.
* - bit1: this server has marked the device as elevated risk.
*
* A risk bit is never cleared automatically and neither bit is interpreted as
* proof of identity.
*/
object DeviceCheckBitSemantics {
const val SIGNUP_TRIAL_CLAIMED_BIT = 0
const val ELEVATED_RISK_BIT = 1
}
sealed interface DeviceCheckQuery {
data class Found(val state: DeviceCheckState) : DeviceCheckQuery
data object NotFound : DeviceCheckQuery
}
interface AppleDeviceCheckClient {
suspend fun query(deviceToken: String): DeviceCheckQuery
suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean)
}
class UnavailableAppleDeviceCheckClient : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
throw DeviceCheckUnavailableException("DeviceCheck credentials are not configured")
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean): Unit =
throw DeviceCheckUnavailableException("DeviceCheck credentials are not configured")
}
class DeviceCheckJwtGenerator(
private val teamId: String,
private val keyId: String,
privateKeyPem: String,
private val clock: Clock = Clock.systemUTC(),
) {
private val privateKey = loadPrivateKey(privateKeyPem)
init {
require(teamId.isNotBlank()) { "DeviceCheck team ID is required" }
require(keyId.isNotBlank()) { "DeviceCheck key ID is required" }
}
fun create(): String {
val now = clock.instant()
val claims = JWTClaimsSet.Builder()
.issuer(teamId)
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plus(JWT_LIFETIME)))
.build()
return SignedJWT(
JWSHeader.Builder(JWSAlgorithm.ES256).keyID(keyId).build(),
claims,
).apply {
sign(ECDSASigner(privateKey))
}.serialize()
}
private fun loadPrivateKey(pem: String): ECPrivateKey {
val encoded = pem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(Regex("\\s"), "")
return runCatching {
KeyFactory.getInstance("EC")
.generatePrivate(PKCS8EncodedKeySpec(Base64.getDecoder().decode(encoded))) as ECPrivateKey
}.getOrElse {
throw DeviceCheckUnavailableException("DeviceCheck private key is invalid", it)
}
}
private companion object {
val JWT_LIFETIME: Duration = Duration.ofMinutes(55)
}
}
class KtorAppleDeviceCheckClient(
private val httpClient: HttpClient,
private val jwtGenerator: DeviceCheckJwtGenerator,
environment: AppleServiceEnvironment,
private val clock: Clock = Clock.systemUTC(),
private val newTransactionId: () -> UUID = UUID::randomUUID,
private val timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS,
) : AppleDeviceCheckClient {
private val baseUrl = when (environment) {
AppleServiceEnvironment.DEVELOPMENT -> "https://api.development.devicecheck.apple.com"
AppleServiceEnvironment.PRODUCTION -> "https://api.devicecheck.apple.com"
}
override suspend fun query(deviceToken: String): DeviceCheckQuery {
validateToken(deviceToken)
val response = execute(
path = "/v1/query_two_bits",
body = DeviceCheckRequest(
deviceToken = deviceToken,
transactionId = newTransactionId().toString(),
timestamp = clock.millis(),
),
)
if (!response.status.isSuccess()) {
throwForStatus(response.status.value)
}
if (response.body.trim() == BIT_STATE_NOT_FOUND_RESPONSE) {
return DeviceCheckQuery.NotFound
}
val parsed = runCatching {
JSON.decodeFromString<DeviceCheckResponse>(response.body)
}.getOrElse {
throw DeviceCheckUnavailableException("DeviceCheck query returned an invalid response", it)
}
return DeviceCheckQuery.Found(
DeviceCheckState(parsed.bit0, parsed.bit1, parsed.lastUpdateTime),
)
}
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) {
validateToken(deviceToken)
val response = execute(
path = "/v1/update_two_bits",
body = DeviceCheckRequest(
deviceToken = deviceToken,
transactionId = newTransactionId().toString(),
timestamp = clock.millis(),
bit0 = bit0,
bit1 = bit1,
),
)
if (!response.status.isSuccess()) {
throwForStatus(response.status.value)
}
}
private suspend fun execute(path: String, body: DeviceCheckRequest): DeviceCheckHttpResponse =
runCatching {
httpClient.post("$baseUrl$path") {
timeout {
connectTimeoutMillis = timeoutMillis
socketTimeoutMillis = timeoutMillis
requestTimeoutMillis = timeoutMillis
}
contentType(ContentType.Application.Json)
header(HttpHeaders.Authorization, "Bearer ${jwtGenerator.create()}")
setBody(JSON.encodeToString(DeviceCheckRequest.serializer(), body))
}.let { DeviceCheckHttpResponse(it.status, it.bodyAsText()) }
}.getOrElse {
if (it is DeviceCheckException) throw it
if (it is CancellationException) throw it
if (it is HttpRequestTimeoutException) {
throw DeviceCheckUnavailableException("DeviceCheck request timed out", it)
}
throw DeviceCheckUnavailableException("DeviceCheck request failed", it)
}
private fun throwForStatus(status: Int): Nothing {
when (status) {
400, 422 ->
throw DeviceCheckRejectedException("DeviceCheck rejected the device token")
401, 403 ->
throw DeviceCheckUnavailableException("DeviceCheck credentials were rejected by Apple")
408, 409, 425, 429 ->
throw DeviceCheckUnavailableException("DeviceCheck request can be retried")
in 500..599 ->
throw DeviceCheckUnavailableException("DeviceCheck is temporarily unavailable")
else ->
throw DeviceCheckUnavailableException("DeviceCheck returned an unexpected HTTP status")
}
}
private fun validateToken(deviceToken: String) {
if (deviceToken.isBlank() || deviceToken.length > MAX_DEVICE_TOKEN_LENGTH) {
throw DeviceCheckRejectedException("DeviceCheck token format is invalid")
}
runCatching { Base64.getDecoder().decode(deviceToken) }.getOrElse {
throw DeviceCheckRejectedException("DeviceCheck token format is invalid")
}
}
private data class DeviceCheckHttpResponse(
val status: io.ktor.http.HttpStatusCode,
val body: String,
)
private companion object {
const val MAX_DEVICE_TOKEN_LENGTH = 8_192
const val DEFAULT_TIMEOUT_MILLIS = 5_000L
const val BIT_STATE_NOT_FOUND_RESPONSE = "Failed to find bit state"
val JSON = Json { ignoreUnknownKeys = true }
}
}
class DeviceCheckRiskService(
private val client: AppleDeviceCheckClient,
) {
suspend fun markElevatedRisk(deviceToken: String) {
val current = when (val query = client.query(deviceToken)) {
is DeviceCheckQuery.Found -> query.state
DeviceCheckQuery.NotFound -> DeviceCheckState(false, false, null)
}
if (!current.bit1) {
client.update(deviceToken, bit0 = current.bit0, bit1 = true)
}
}
}
class RemoteDeviceCheckVerifier(
private val client: AppleDeviceCheckClient,
) : DeviceCheckVerifier {
override suspend fun verify(deviceToken: String): IntegrityVerification =
try {
client.query(deviceToken)
IntegrityVerification.Verified
} catch (exception: DeviceCheckRejectedException) {
IntegrityVerification.Rejected(exception.message ?: "DeviceCheck rejected the token")
} catch (exception: DeviceCheckUnavailableException) {
IntegrityVerification.Unavailable(exception.message ?: "DeviceCheck is unavailable")
}
}
open class DeviceCheckException(message: String, cause: Throwable? = null) :
IllegalStateException(message, cause)
class DeviceCheckRejectedException(message: String, cause: Throwable? = null) :
DeviceCheckException(message, cause)
class DeviceCheckUnavailableException(message: String, cause: Throwable? = null) :
DeviceCheckException(message, cause)
enum class TrialClaimStatus {
RESERVED,
APPLE_MARKED,
COMPLETED,
REJECTED,
}
data class TrialClaim(
val tokenHash: String,
val accountId: UUID,
val status: TrialClaimStatus,
)
sealed interface BeginTrialClaim {
data class Owned(val claim: TrialClaim) : BeginTrialClaim
data object ClaimedByAnotherAccount : BeginTrialClaim
}
interface DeviceCheckTrialClaimRepository {
suspend fun begin(tokenHash: String, accountId: UUID, now: Instant): BeginTrialClaim
suspend fun transition(tokenHash: String, status: TrialClaimStatus, now: Instant)
}
fun interface TrialCreditGranter {
suspend fun grant(accountId: UUID)
}
interface DeviceCheckTrialMutex {
suspend fun <T> withLock(block: suspend () -> T): T
}
class MysqlDeviceCheckTrialMutex(
private val databaseFactory: DatabaseFactory,
) : DeviceCheckTrialMutex {
override suspend fun <T> withLock(block: suspend () -> T): T =
try {
databaseFactory.withMysqlNamedLock(
name = GLOBAL_TRIAL_LOCK_NAME,
timeoutSeconds = LOCK_TIMEOUT_SECONDS,
block = block,
)
} catch (exception: DeviceCheckException) {
throw exception
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
throw DeviceCheckUnavailableException("DeviceCheck trial lock is unavailable", exception)
}
private companion object {
const val GLOBAL_TRIAL_LOCK_NAME = "osg-devicecheck-trial-v1"
const val LOCK_TIMEOUT_SECONDS = 15
}
}
private object LocalDeviceCheckTrialMutex : DeviceCheckTrialMutex {
override suspend fun <T> withLock(block: suspend () -> T): T = block()
}
fun interface SignupTrialClaimService {
suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): Boolean
}
class DeviceCheckTrialService(
private val repository: DeviceCheckTrialClaimRepository,
private val client: AppleDeviceCheckClient,
private val creditGranter: TrialCreditGranter,
private val policy: IntegrityPolicy,
private val mutex: DeviceCheckTrialMutex = LocalDeviceCheckTrialMutex,
private val clock: Clock = Clock.systemUTC(),
) : SignupTrialClaimService {
override suspend fun claimAndGrant(accountId: UUID, deviceToken: String?): Boolean {
if (deviceToken.isNullOrBlank()) return false
val tokenHash = sha256Hex(deviceToken)
val owned = when (val result = repository.begin(tokenHash, accountId, clock.instant())) {
is BeginTrialClaim.Owned -> result.claim
BeginTrialClaim.ClaimedByAnotherAccount -> return false
}
return try {
mutex.withLock {
completeOwnedClaim(owned, deviceToken)
}
} catch (exception: DeviceCheckRejectedException) {
repository.transition(tokenHash, TrialClaimStatus.REJECTED, clock.instant())
false
} catch (exception: DeviceCheckUnavailableException) {
if (policy == IntegrityPolicy.ENFORCE) {
throw ExternalServiceUnavailableException("DeviceCheck")
}
false
}
}
private suspend fun completeOwnedClaim(claim: TrialClaim, deviceToken: String): Boolean {
when (claim.status) {
TrialClaimStatus.COMPLETED -> return true
TrialClaimStatus.REJECTED -> return false
TrialClaimStatus.APPLE_MARKED -> {
grantAndComplete(claim)
return true
}
TrialClaimStatus.RESERVED -> Unit
}
val state = when (val query = client.query(deviceToken)) {
is DeviceCheckQuery.Found -> query.state
DeviceCheckQuery.NotFound -> DeviceCheckState(false, false, null)
}
if (state.bit0) {
repository.transition(claim.tokenHash, TrialClaimStatus.REJECTED, clock.instant())
return false
}
// Apple has no compare-and-set API. Marking first is intentionally conservative:
// a crash can forfeit a trial, but can never issue credits before the global bit is set.
client.update(deviceToken, bit0 = true, bit1 = state.bit1)
val confirmed = when (val confirmation = client.query(deviceToken)) {
is DeviceCheckQuery.Found -> confirmation.state.bit0
DeviceCheckQuery.NotFound -> false
}
if (!confirmed) {
throw DeviceCheckUnavailableException("DeviceCheck trial mark could not be confirmed")
}
repository.transition(claim.tokenHash, TrialClaimStatus.APPLE_MARKED, clock.instant())
grantAndComplete(claim)
return true
}
private suspend fun grantAndComplete(claim: TrialClaim) {
creditGranter.grant(claim.accountId)
repository.transition(claim.tokenHash, TrialClaimStatus.COMPLETED, clock.instant())
}
}
private object DeviceCheckTrialClaims : Table("devicecheck_trial_claims") {
val tokenHash = char("device_token_hash", 64)
val accountId = varchar("account_id", 36)
val status = enumerationByName<TrialClaimStatus>("status", 16)
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(tokenHash)
}
class ExposedDeviceCheckTrialClaimRepository(
private val databaseFactory: DatabaseFactory,
) : DeviceCheckTrialClaimRepository {
override suspend fun begin(
tokenHash: String,
accountId: UUID,
now: Instant,
): BeginTrialClaim = databaseFactory.query {
DeviceCheckTrialClaims.insertIgnore {
it[DeviceCheckTrialClaims.tokenHash] = tokenHash
it[DeviceCheckTrialClaims.accountId] = accountId.toString()
it[status] = TrialClaimStatus.RESERVED
it[createdAt] = now
it[updatedAt] = now
}
val claim = DeviceCheckTrialClaims.selectAll()
.where { DeviceCheckTrialClaims.tokenHash eq tokenHash }
.forUpdate()
.single()
.toTrialClaim()
if (claim.accountId == accountId) {
BeginTrialClaim.Owned(claim)
} else {
BeginTrialClaim.ClaimedByAnotherAccount
}
}
override suspend fun transition(tokenHash: String, status: TrialClaimStatus, now: Instant) {
databaseFactory.query {
DeviceCheckTrialClaims.update({ DeviceCheckTrialClaims.tokenHash eq tokenHash }) {
it[DeviceCheckTrialClaims.status] = status
it[updatedAt] = now
}
}
}
}
fun createDeviceCheckClient(
httpClient: HttpClient,
appleConfig: AppleConfig,
environment: AppleServiceEnvironment,
): AppleDeviceCheckClient? {
val teamId = appleConfig.teamId ?: return null
val keyId = appleConfig.keyId ?: return null
val privateKey = appleConfig.privateKeyPem ?: return null
return KtorAppleDeviceCheckClient(
httpClient,
DeviceCheckJwtGenerator(teamId, keyId, privateKey),
environment,
)
}
private fun ResultRow.toTrialClaim() = TrialClaim(
tokenHash = this[DeviceCheckTrialClaims.tokenHash],
accountId = UUID.fromString(this[DeviceCheckTrialClaims.accountId]),
status = this[DeviceCheckTrialClaims.status],
)
private fun sha256Hex(value: String): String =
java.security.MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
@Serializable
private data class DeviceCheckRequest(
@SerialName("device_token") val deviceToken: String,
@SerialName("transaction_id") val transactionId: String,
val timestamp: Long,
val bit0: Boolean? = null,
val bit1: Boolean? = null,
)
@Serializable
private data class DeviceCheckResponse(
val bit0: Boolean,
val bit1: Boolean,
@SerialName("last_update_time") val lastUpdateTime: String? = null,
)
@@ -0,0 +1,188 @@
package com.osglab.account.features.integrity
import kotlinx.coroutines.CancellationException
import java.util.Base64
import java.util.UUID
enum class IntegrityEvidenceState {
VERIFIED,
REJECTED,
UNSUPPORTED,
TEMPORARILY_UNAVAILABLE,
}
enum class IntegrityEligibility {
ELIGIBLE,
INELIGIBLE,
RETRY_LATER,
}
enum class IntegrityRiskUseCase {
SIGNUP_TRIAL,
REFERRAL_REWARD,
}
data class IntegrityRiskRequest(
val accountId: UUID,
val useCase: IntegrityRiskUseCase,
val deviceCheckToken: String? = null,
val appAttestKeyId: String? = null,
)
data class IntegrityRiskDecision(
val eligibility: IntegrityEligibility,
val evidenceState: IntegrityEvidenceState,
)
/**
* Port consumed by credits and referrals. Unsupported devices are explicitly
* ineligible for promotional value; transient provider failures ask callers
* to retry and never silently grant a reward.
*/
fun interface IntegrityRiskPort {
suspend fun assess(request: IntegrityRiskRequest): IntegrityRiskDecision
}
class DefaultIntegrityRiskPort(
private val deviceCheckClient: AppleDeviceCheckClient,
private val appAttestRepository: AppAttestRepository,
) : IntegrityRiskPort {
override suspend fun assess(request: IntegrityRiskRequest): IntegrityRiskDecision =
when (request.useCase) {
IntegrityRiskUseCase.SIGNUP_TRIAL -> assessTrial(request.deviceCheckToken)
IntegrityRiskUseCase.REFERRAL_REWARD ->
assessReferral(
request.accountId,
request.appAttestKeyId,
request.deviceCheckToken,
)
}
private suspend fun assessTrial(token: String?): IntegrityRiskDecision {
if (token.isNullOrBlank()) return unsupported()
return try {
when (val query = deviceCheckClient.query(token)) {
DeviceCheckQuery.NotFound -> eligible()
is DeviceCheckQuery.Found -> {
// bit0 = signup trial already consumed; bit1 = server risk flag.
if (query.state.bit0 || query.state.bit1) rejected() else eligible()
}
}
} catch (_: DeviceCheckRejectedException) {
rejected()
} catch (_: DeviceCheckUnavailableException) {
retryLater()
} catch (exception: CancellationException) {
throw exception
}
}
private suspend fun assessReferral(
accountId: UUID,
keyId: String?,
deviceCheckToken: String?,
): IntegrityRiskDecision {
if (keyId.isNullOrBlank() || deviceCheckToken.isNullOrBlank()) return unsupported()
return try {
val key = appAttestRepository.findKey(keyId) ?: return rejected()
if (key.status != AppAttestKeyStatus.ACTIVE || key.accountId != accountId) {
return rejected()
}
when (val query = deviceCheckClient.query(deviceCheckToken)) {
DeviceCheckQuery.NotFound -> eligible()
is DeviceCheckQuery.Found -> {
// A consumed trial is valid for referrals; elevated risk is not.
if (query.state.bit1) rejected() else eligible()
}
}
} catch (_: DeviceCheckRejectedException) {
rejected()
} catch (_: DeviceCheckUnavailableException) {
retryLater()
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
retryLater()
}
}
private fun eligible() =
IntegrityRiskDecision(IntegrityEligibility.ELIGIBLE, IntegrityEvidenceState.VERIFIED)
private fun rejected() =
IntegrityRiskDecision(IntegrityEligibility.INELIGIBLE, IntegrityEvidenceState.REJECTED)
private fun unsupported() =
IntegrityRiskDecision(IntegrityEligibility.INELIGIBLE, IntegrityEvidenceState.UNSUPPORTED)
private fun retryLater() =
IntegrityRiskDecision(IntegrityEligibility.RETRY_LATER, IntegrityEvidenceState.TEMPORARILY_UNAVAILABLE)
}
enum class GatewayIntegrityDecision {
ALLOW,
DENY,
RETRY_LATER,
}
data class GatewayCostIntegrityRequest(
val accountId: UUID,
val keyId: String,
val challengeId: String,
val challengeBase64Url: String,
val assertionBase64: String,
/**
* Server-computed hash of canonical cost request metadata. Prompt, audio
* and model response bodies must never be passed through this port.
*/
val expectedClientDataHash: ByteArray,
)
/**
* Port consumed by gateway before an upstream cost is incurred.
*/
fun interface GatewayIntegrityPort {
suspend fun authorize(request: GatewayCostIntegrityRequest): GatewayIntegrityDecision
}
class AppAttestGatewayIntegrityPort(
private val appAttestService: AppAttestService,
private val appAttestRepository: AppAttestRepository,
) : GatewayIntegrityPort {
override suspend fun authorize(
request: GatewayCostIntegrityRequest,
): GatewayIntegrityDecision = try {
val key = appAttestRepository.findKey(request.keyId)
?: return GatewayIntegrityDecision.DENY
if (key.status != AppAttestKeyStatus.ACTIVE || key.accountId != request.accountId) {
return GatewayIntegrityDecision.DENY
}
val challenge = try {
Base64.getUrlDecoder().decode(request.challengeBase64Url)
} catch (_: IllegalArgumentException) {
return GatewayIntegrityDecision.DENY
}
if (challenge.size != 32 || request.expectedClientDataHash.size != 32) {
return GatewayIntegrityDecision.DENY
}
appAttestService.verifyBoundAssertion(
challengeId = request.challengeId,
challenge = challenge,
keyId = request.keyId,
assertionObject = request.assertionBase64,
expectedClientDataHash = request.expectedClientDataHash,
expectedAccountId = request.accountId,
)
GatewayIntegrityDecision.ALLOW
} catch (_: AppAttestRejectedException) {
GatewayIntegrityDecision.DENY
} catch (_: com.osglab.account.common.errors.InvalidRequestException) {
GatewayIntegrityDecision.DENY
} catch (_: AppAttestUnavailableException) {
GatewayIntegrityDecision.RETRY_LATER
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
GatewayIntegrityDecision.RETRY_LATER
}
}
@@ -0,0 +1,120 @@
package com.osglab.account.features.integrity
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.config.IntegrityConfig
import com.osglab.account.config.IntegrityPolicy
data class IntegrityEvidence(
val deviceCheckToken: String? = null,
val appAttest: AppAttestEvidence? = null,
)
data class AppAttestEvidence(
val keyId: String,
val challengeId: String,
val assertion: String,
/**
* Base64URL challenge returned by /v1/integrity/challenges. New clients
* must echo it because persistence intentionally stores only its hash.
*/
val challenge: String? = null,
)
data class AppleSignInIntegrityPayload(
val identityToken: String,
val authorizationCode: String,
val nonce: String,
)
data class VerifiedIntegrityEvidence(
val deviceCheckTokenForTrial: String?,
val appAttestKeyId: String?,
)
sealed interface IntegrityVerification {
data object Verified : IntegrityVerification
data class Rejected(val reason: String) : IntegrityVerification
data class Unavailable(val reason: String) : IntegrityVerification
}
interface DeviceCheckVerifier {
suspend fun verify(deviceToken: String): IntegrityVerification
}
interface AppAttestVerifier {
suspend fun verify(
evidence: AppAttestEvidence,
payload: AppleSignInIntegrityPayload,
): IntegrityVerification
suspend fun bindKeyToAccount(keyId: String, accountId: java.util.UUID) = Unit
}
class IntegrityService(
private val config: IntegrityConfig,
private val deviceCheckVerifier: DeviceCheckVerifier,
private val appAttestVerifier: AppAttestVerifier,
) {
suspend fun verifyAppleSignIn(
evidence: IntegrityEvidence,
payload: AppleSignInIntegrityPayload,
): VerifiedIntegrityEvidence {
val suppliedDeviceToken = evidence.deviceCheckToken?.takeIf(String::isNotBlank)
val deviceCheck = suppliedDeviceToken
?.let { deviceCheckVerifier.verify(it) }
?: IntegrityVerification.Unavailable("DeviceCheck evidence was not supplied")
enforce("DeviceCheck", config.deviceCheckPolicy, deviceCheck)
val appAttest = evidence.appAttest
?.let { appAttestVerifier.verify(it, payload) }
?: IntegrityVerification.Unavailable("App Attest evidence was not supplied")
enforce("App Attest", config.appAttestPolicy, appAttest)
return VerifiedIntegrityEvidence(
// A fail-open MONITOR result permits login, never a credit grant.
deviceCheckTokenForTrial = suppliedDeviceToken
?.takeIf { deviceCheck == IntegrityVerification.Verified },
appAttestKeyId = evidence.appAttest?.keyId
?.takeIf { appAttest == IntegrityVerification.Verified },
)
}
suspend fun bindVerifiedKey(keyId: String?, accountId: java.util.UUID) {
keyId?.let { appAttestVerifier.bindKeyToAccount(it, accountId) }
}
private fun enforce(
name: String,
policy: IntegrityPolicy,
result: IntegrityVerification,
) {
when (result) {
IntegrityVerification.Verified -> Unit
is IntegrityVerification.Rejected ->
throw InvalidRequestException("$name verification failed")
is IntegrityVerification.Unavailable -> {
if (policy == IntegrityPolicy.ENFORCE) {
throw ExternalServiceUnavailableException(name)
}
// MONITOR is deliberately fail-open only for missing/unavailable verification.
}
}
}
}
class UnavailableDeviceCheckVerifier(
private val reason: String = "DeviceCheck HTTP verifier credentials are not configured",
) : DeviceCheckVerifier {
override suspend fun verify(deviceToken: String): IntegrityVerification =
IntegrityVerification.Unavailable(reason)
}
class UnavailableAppAttestVerifier(
private val reason: String = "App Attest verifier is unavailable",
) : AppAttestVerifier {
override suspend fun verify(
evidence: AppAttestEvidence,
payload: AppleSignInIntegrityPayload,
): IntegrityVerification = IntegrityVerification.Unavailable(reason)
}
@@ -0,0 +1,293 @@
package com.osglab.account.features.inviteweb
import com.osglab.account.config.AppConfig
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.withCharset
import io.ktor.server.application.ApplicationCall
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import org.koin.ktor.ext.getKoin
import java.net.URI
import java.security.SecureRandom
import java.util.Base64
/**
* Read-only boundary used by the public page to verify a referral code.
*
* Implementations must preserve case, apply campaign validity rules, use a bounded database query,
* and never log the code.
*/
fun interface ReferralLookupPort {
suspend fun isValid(code: String): Boolean
}
data class InviteWebConfig(
val appStoreUrl: String,
val appleAppId: String,
val universalLinkBaseUrl: String = "https://osglab.com/i",
val lookupTimeoutMillis: Long = 1_500,
) {
init {
val appStore = validateHttpsUrl(appStoreUrl, "APP_STORE_URL", allowQuery = true)
require(
appStore.host.equals("apps.apple.com", ignoreCase = true) &&
(appStore.port == -1 || appStore.port == 443) &&
APP_STORE_PATH.matches(appStore.path)
) {
"APP_STORE_URL must be an official apps.apple.com app URL ending in a numeric App ID"
}
val universalLink = validateHttpsUrl(
universalLinkBaseUrl,
"INVITE_BASE_URL",
allowQuery = false,
)
require(
universalLink.host.equals("osglab.com", ignoreCase = true) &&
(universalLink.port == -1 || universalLink.port == 443) &&
universalLink.path.trimEnd('/') == "/i"
) {
"INVITE_BASE_URL must be https://osglab.com/i"
}
require(APPLE_APP_ID.matches(appleAppId)) {
"APPLE_APP_ID must be a Team ID followed by a bundle ID"
}
require(lookupTimeoutMillis in 100..10_000) {
"Invitation lookup timeout must be between 100 and 10000 milliseconds"
}
}
}
sealed interface InvitePageResult {
data class Found(val html: String, val cspNonce: String) : InvitePageResult
data object Invalid : InvitePageResult
data object TemporarilyUnavailable : InvitePageResult
}
/**
* Owns invitation validation and rendering so the Ktor route remains a transport adapter.
*/
class InvitePageService(
private val referralLookup: ReferralLookupPort,
private val config: InviteWebConfig,
) {
private val appStoreUrl = validateHttpsUrl(config.appStoreUrl, "APP_STORE_URL", allowQuery = true)
.toASCIIString()
.escapeHtml()
private val universalLinkBaseUrl = validateHttpsUrl(
config.universalLinkBaseUrl,
"INVITE_BASE_URL",
allowQuery = false,
).toASCIIString().trimEnd('/')
val aasaJson: String = AASA_TEMPLATE.replace(APPLE_APP_ID_TOKEN, config.appleAppId)
suspend fun render(code: String?): InvitePageResult {
val validCode = code?.takeIf(INVITE_CODE::matches) ?: return InvitePageResult.Invalid
val valid = try {
withTimeout(config.lookupTimeoutMillis) {
referralLookup.isValid(validCode)
}
} catch (_: TimeoutCancellationException) {
return InvitePageResult.TemporarilyUnavailable
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
return InvitePageResult.TemporarilyUnavailable
}
if (!valid) return InvitePageResult.Invalid
val nonce = createNonce()
val universalLink = "$universalLinkBaseUrl/$validCode".escapeHtml()
return InvitePageResult.Found(
html = INVITE_TEMPLATE
.replace(CODE_TOKEN, validCode.escapeHtml())
.replace(APP_STORE_URL_TOKEN, appStoreUrl)
.replace(UNIVERSAL_LINK_TOKEN, universalLink)
.replace(NONCE_TOKEN, nonce),
cspNonce = nonce,
)
}
}
/**
* Public composition point for the first-party invitation page and AASA document.
*
* Invitation codes are 16 random bytes encoded as unpadded Base64URL: exactly 22
* case-sensitive characters from A-Z, a-z, 0-9, "_" and "-".
*/
fun Route.configureInviteWebRoutes() {
val koin = getKoin()
val appConfig = koin.get<AppConfig>()
val teamId = requireNotNull(appConfig.apple.teamId) {
"APPLE_TEAM_ID is required to publish the AASA document"
}
configureInviteWebRoutes(
referralLookup = koin.get<ReferralLookupPort>(),
config = InviteWebConfig(
appStoreUrl = appConfig.appStoreUrl,
appleAppId = "$teamId.${appConfig.apple.clientId}",
universalLinkBaseUrl = appConfig.inviteBaseUrl,
),
)
}
fun Route.configureInviteWebRoutes(
referralLookup: ReferralLookupPort,
config: InviteWebConfig,
) {
val service = InvitePageService(referralLookup, config)
get("/i/{code}") {
when (val result = service.render(call.parameters["code"])) {
is InvitePageResult.Found -> {
call.setPublicAssetHeaders(inviteNonce = result.cspNonce)
call.respondText(
text = result.html,
contentType = ContentType.Text.Html.withCharset(Charsets.UTF_8),
status = HttpStatusCode.OK,
)
}
InvitePageResult.Invalid -> {
call.setPublicAssetHeaders()
call.respondInvalidInvitation()
}
InvitePageResult.TemporarilyUnavailable -> {
call.setPublicAssetHeaders()
call.respondLookupUnavailable()
}
}
}
get("/.well-known/apple-app-site-association") {
call.respondAasa(service.aasaJson)
}
get("/apple-app-site-association") {
call.respondAasa(service.aasaJson)
}
}
private suspend fun ApplicationCall.respondAasa(aasa: String) {
setPublicAssetHeaders()
respondText(aasa, ContentType.Application.Json, HttpStatusCode.OK)
}
private suspend fun ApplicationCall.respondInvalidInvitation() {
respondText(
text = "邀请链接无效或已失效 / This invitation link is invalid or expired",
contentType = ContentType.Text.Plain.withCharset(Charsets.UTF_8),
status = HttpStatusCode.NotFound,
)
}
private suspend fun ApplicationCall.respondLookupUnavailable() {
response.headers.append(HttpHeaders.RetryAfter, "30")
respondText(
text = "邀请服务暂时不可用 / Invitation service is temporarily unavailable",
contentType = ContentType.Text.Plain.withCharset(Charsets.UTF_8),
status = HttpStatusCode.ServiceUnavailable,
)
}
private fun ApplicationCall.setPublicAssetHeaders(inviteNonce: String? = null) {
val contentSecurityPolicy = if (inviteNonce == null) {
"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
} else {
"default-src 'none'; " +
"script-src 'nonce-$inviteNonce'; script-src-attr 'none'; " +
"style-src 'nonce-$inviteNonce'; style-src-attr 'none'; " +
"img-src 'none'; font-src 'none'; connect-src 'none'; media-src 'none'; " +
"object-src 'none'; frame-src 'none'; worker-src 'none'; manifest-src 'none'; " +
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests"
}
response.headers.append("Content-Security-Policy", contentSecurityPolicy)
response.headers.append(HttpHeaders.CacheControl, "no-store, max-age=0")
response.headers.append(HttpHeaders.Pragma, "no-cache")
response.headers.append(HttpHeaders.Expires, "0")
response.headers.append(HttpHeaders.ContentLanguage, "zh-CN, en")
response.headers.append("Referrer-Policy", "no-referrer")
response.headers.append(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), clipboard-write=(self)",
)
response.headers.append("Cross-Origin-Opener-Policy", "same-origin")
response.headers.append("Cross-Origin-Resource-Policy", "same-origin")
response.headers.append("X-Content-Type-Options", "nosniff")
response.headers.append("X-Frame-Options", "DENY")
response.headers.append("X-Permitted-Cross-Domain-Policies", "none")
response.headers.append("X-Robots-Tag", "noindex, nofollow, noarchive")
}
private fun validateHttpsUrl(value: String, name: String, allowQuery: Boolean): URI {
val uri = runCatching { URI(value.trim()) }
.getOrElse { throw IllegalArgumentException("$name must be a valid HTTPS URL", it) }
require(
uri.scheme.equals("https", ignoreCase = true) &&
!uri.host.isNullOrBlank() &&
uri.userInfo == null &&
uri.fragment == null &&
(allowQuery || uri.query == null)
) {
"$name must be an absolute HTTPS URL without user information or a fragment"
}
return uri
}
private fun String.escapeHtml(): String =
buildString(length) {
this@escapeHtml.forEach { character ->
append(
when (character) {
'&' -> "&amp;"
'<' -> "&lt;"
'>' -> "&gt;"
'"' -> "&quot;"
'\'' -> "&#39;"
else -> character
},
)
}
}
private fun createNonce(): String =
ByteArray(NONCE_BYTES)
.also(SECURE_RANDOM::nextBytes)
.let { Base64.getUrlEncoder().withoutPadding().encodeToString(it) }
private fun loadTemplate(resource: String, requiredTokens: Set<String>): String {
val stream = object {}.javaClass.getResourceAsStream(resource)
?: error("Missing classpath resource: $resource")
return stream.bufferedReader(Charsets.UTF_8).use { it.readText() }.also { template ->
require(requiredTokens.all(template::contains)) {
"$resource is missing a required placeholder"
}
}
}
private const val INVITE_TEMPLATE_RESOURCE = "/invite/index.html"
private const val AASA_TEMPLATE_RESOURCE = "/invite/apple-app-site-association.json"
private const val CODE_TOKEN = "{{INVITE_CODE}}"
private const val APP_STORE_URL_TOKEN = "{{APP_STORE_URL}}"
private const val UNIVERSAL_LINK_TOKEN = "{{UNIVERSAL_LINK}}"
private const val APPLE_APP_ID_TOKEN = "{{APPLE_APP_ID}}"
private const val NONCE_TOKEN = "{{CSP_NONCE}}"
private const val NONCE_BYTES = 18
private val INVITE_CODE = Regex("[A-Za-z0-9_-]{22}")
private val APPLE_APP_ID = Regex("[A-Z0-9]{10}\\.[A-Za-z0-9.-]+")
private val APP_STORE_PATH = Regex("/.+/id[0-9]+")
private val SECURE_RANDOM = SecureRandom()
private val INVITE_TEMPLATE: String by lazy {
loadTemplate(
INVITE_TEMPLATE_RESOURCE,
setOf(CODE_TOKEN, APP_STORE_URL_TOKEN, UNIVERSAL_LINK_TOKEN, NONCE_TOKEN),
)
}
private val AASA_TEMPLATE: String by lazy {
loadTemplate(AASA_TEMPLATE_RESOURCE, setOf(APPLE_APP_ID_TOKEN))
}
@@ -0,0 +1,147 @@
package com.osglab.account.features.referrals.domain
import java.security.SecureRandom
import java.time.DateTimeException
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.UUID
val DEFAULT_REFERRAL_CAMPAIGN_ID: UUID =
UUID.fromString("00000000-0000-0000-0000-000000000001")
data class ReferralCampaign(
val id: UUID,
val name: String,
val startsAt: Instant,
val endsAt: Instant?,
val bindingWindowSeconds: Long,
val inviterRewardCredits: Long,
val inviteeRewardCredits: Long,
val maxRewardedBindings: Long?,
val budgetCredits: Long?,
val enabled: Boolean,
) {
init {
require(name.isNotBlank()) { "Campaign name must not be blank" }
require(endsAt == null || endsAt > startsAt) { "Campaign interval is invalid" }
require(bindingWindowSeconds > 0) { "Binding window must be positive" }
require(inviterRewardCredits > 0 && inviteeRewardCredits > 0) {
"Referral rewards must be positive"
}
require(inviterRewardCredits <= Long.MAX_VALUE - inviteeRewardCredits) {
"Combined referral reward exceeds the supported integer range"
}
require(maxRewardedBindings == null || maxRewardedBindings > 0) {
"Campaign reward cap must be positive"
}
require(budgetCredits == null || budgetCredits >= rewardCost) {
"Campaign budget must fund at least one bilateral reward"
}
}
fun isActive(at: Instant): Boolean =
enabled && !at.isBefore(startsAt) && (endsAt == null || at < endsAt)
val rewardCost: Long
get() = Math.addExact(inviterRewardCredits, inviteeRewardCredits)
}
data class ReferralCampaignBudget(
val campaignId: UUID,
val rewardedBindings: Long,
val spentCredits: Long,
val updatedAt: Instant,
) {
init {
require(rewardedBindings >= 0) { "Rewarded binding count must not be negative" }
require(spentCredits >= 0) { "Spent campaign credits must not be negative" }
}
}
enum class ReferralRewardStatus {
PENDING,
REWARDED,
INELIGIBLE_BUDGET,
}
data class ReferralCode(
val id: UUID,
val ownerUserId: UUID,
val ownerIdentityFingerprint: String?,
val code: String,
val createdAt: Instant,
val campaignId: UUID? = null,
)
data class ReferralBinding(
val id: UUID,
val inviterUserId: UUID,
val inviteeUserId: UUID,
val codeId: UUID,
val boundAt: Instant,
val rewardedAt: Instant?,
val rewardSettlementId: UUID?,
val campaignId: UUID? = null,
val rewardStatus: ReferralRewardStatus = if (rewardedAt == null) {
ReferralRewardStatus.PENDING
} else {
ReferralRewardStatus.REWARDED
},
)
object ReferralBindingRules {
fun isWithinWindow(
registeredAt: Instant,
attemptedAt: Instant,
bindingWindow: Duration,
): Boolean {
require(!bindingWindow.isNegative && !bindingWindow.isZero) {
"Referral binding window must be positive"
}
if (attemptedAt.isBefore(registeredAt)) return false
val deadline = try {
registeredAt.plus(bindingWindow)
} catch (_: DateTimeException) {
Instant.MAX
} catch (_: ArithmeticException) {
Instant.MAX
}
return !attemptedAt.isAfter(deadline)
}
fun isSelfReferral(
inviteeUserId: UUID,
inviteeIdentityFingerprint: String,
code: ReferralCode,
): Boolean =
code.ownerUserId == inviteeUserId ||
code.ownerIdentityFingerprint?.equals(
inviteeIdentityFingerprint,
ignoreCase = true,
) == true
}
fun interface InviteCodeGenerator {
fun generate(): String
}
class SecureInviteCodeGenerator(
private val secureRandom: SecureRandom = SecureRandom(),
) : InviteCodeGenerator {
override fun generate(): String {
val entropy = ByteArray(16)
secureRandom.nextBytes(entropy)
return Base64.getUrlEncoder().withoutPadding().encodeToString(entropy)
}
}
open class ReferralException(message: String) : RuntimeException(message)
class ReferralConflict(message: String) : ReferralException(message)
class InvalidReferralRequest(message: String) : ReferralException(message)
class ReferralNotFound(message: String) : ReferralException(message)
class ReferralWindowExpired : ReferralException("Referral binding window has expired")
@@ -0,0 +1,83 @@
package com.osglab.account.features.referrals.models
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.services.ReferralProfile
import kotlinx.serialization.Serializable
@Serializable
data class BindReferralRequest(
val code: String,
)
@Serializable
data class ReferralCodeDto(
val code: String,
val campaignId: String?,
val createdAt: String,
) {
companion object {
fun fromDomain(value: ReferralCode) = ReferralCodeDto(
code = value.code,
campaignId = value.campaignId?.toString(),
createdAt = value.createdAt.toString(),
)
}
}
@Serializable
data class ReferralBindingDto(
val boundAt: String,
val rewarded: Boolean,
val rewardStatus: String,
val campaignId: String?,
) {
companion object {
fun fromDomain(value: ReferralBinding) = ReferralBindingDto(
boundAt = value.boundAt.toString(),
rewarded = value.rewardedAt != null,
rewardStatus = value.rewardStatus.name,
campaignId = value.campaignId?.toString(),
)
}
}
@Serializable
data class ReferralCampaignDto(
val id: String,
val name: String,
val startsAt: String,
val endsAt: String?,
val inviterRewardCredits: Long,
val inviteeRewardCredits: Long,
) {
companion object {
fun fromDomain(value: ReferralCampaign) = ReferralCampaignDto(
id = value.id.toString(),
name = value.name,
startsAt = value.startsAt.toString(),
endsAt = value.endsAt?.toString(),
inviterRewardCredits = value.inviterRewardCredits,
inviteeRewardCredits = value.inviteeRewardCredits,
)
}
}
@Serializable
data class ReferralProfileDto(
val code: ReferralCodeDto?,
val binding: ReferralBindingDto?,
) {
companion object {
fun fromDomain(value: ReferralProfile) = ReferralProfileDto(
code = value.code?.let(ReferralCodeDto::fromDomain),
binding = value.binding?.let(ReferralBindingDto::fromDomain),
)
}
}
@Serializable
data class ReferralErrorDto(
val error: String,
)
@@ -0,0 +1,42 @@
package com.osglab.account.features.referrals.repositories
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
import com.osglab.account.features.referrals.domain.ReferralCode
import java.time.Instant
import java.util.UUID
interface ReferralsRepository {
fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID? = null): ReferralCode?
fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode?
fun findCode(code: String): ReferralCode?
fun insertCodeIfAbsent(code: ReferralCode): Boolean
fun findCampaign(id: UUID): ReferralCampaign?
fun listActiveCampaigns(at: Instant): List<ReferralCampaign>
fun lockCampaignBudget(campaignId: UUID): ReferralCampaignBudget
fun updateCampaignBudget(budget: ReferralCampaignBudget)
fun findBinding(inviteeUserId: UUID): ReferralBinding?
fun listBindingsByInviter(inviterUserId: UUID, limit: Int): List<ReferralBinding>
fun lockBinding(inviteeUserId: UUID): ReferralBinding?
fun insertBindingIfAbsent(binding: ReferralBinding): Boolean
fun markRewarded(
bindingId: UUID,
settlementId: UUID,
rewardedAt: Instant,
)
fun markRewardIneligible(bindingId: UUID)
}
@@ -0,0 +1,103 @@
package com.osglab.account.features.referrals.routes
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor
import com.osglab.account.features.referrals.domain.ReferralConflict
import com.osglab.account.features.referrals.domain.ReferralException
import com.osglab.account.features.referrals.domain.InvalidReferralRequest
import com.osglab.account.features.referrals.domain.ReferralNotFound
import com.osglab.account.features.referrals.domain.ReferralWindowExpired
import com.osglab.account.features.referrals.models.BindReferralRequest
import com.osglab.account.features.referrals.models.ReferralBindingDto
import com.osglab.account.features.referrals.models.ReferralCampaignDto
import com.osglab.account.features.referrals.models.ReferralCodeDto
import com.osglab.account.features.referrals.models.ReferralErrorDto
import com.osglab.account.features.referrals.models.ReferralProfileDto
import com.osglab.account.features.referrals.services.ReferralOperations
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
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
import java.util.UUID
class ReferralRouteInstaller(
private val service: ReferralOperations,
private val authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
fun install(parent: Route) {
parent.route("/v1/referrals") {
get("/me") {
call.referralCall(authenticatedUser) { userId ->
ReferralProfileDto.fromDomain(service.getProfile(userId))
}
}
get {
call.referralCall(authenticatedUser) { userId ->
val rawLimit = call.request.queryParameters["limit"]
val limit = rawLimit?.toIntOrNull()
?: if (rawLimit == null) 50 else {
throw InvalidReferralRequest("Referral limit must be an integer")
}
service.listInvited(userId, limit).map(ReferralBindingDto::fromDomain)
}
}
get("/campaigns") {
call.referralCall(authenticatedUser) {
service.listActiveCampaigns().map(ReferralCampaignDto::fromDomain)
}
}
post("/redeem") {
call.referralCall(authenticatedUser) { userId ->
val request = call.receive<BindReferralRequest>()
ReferralBindingDto.fromDomain(service.bind(userId, request.code))
}
}
post("/code") {
call.referralCall(authenticatedUser) { userId ->
ReferralCodeDto.fromDomain(service.getOrCreateCode(userId))
}
}
post("/bind") {
call.referralCall(authenticatedUser) { userId ->
val request = call.receive<BindReferralRequest>()
ReferralBindingDto.fromDomain(service.bind(userId, request.code))
}
}
}
}
}
fun Route.referralRoutes(
service: ReferralOperations,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
ReferralRouteInstaller(service, authenticatedUser).install(this)
}
private suspend fun ApplicationCall.referralCall(
authenticatedUser: AuthenticatedUserExtractor,
block: suspend (UUID) -> Any,
) {
val userId = authenticatedUser.extract(this)
if (userId == null) {
respond(HttpStatusCode.Unauthorized, ReferralErrorDto("Authentication required"))
return
}
try {
respond(block(userId))
} catch (exception: InvalidReferralRequest) {
respond(HttpStatusCode.BadRequest, ReferralErrorDto(exception.message.orEmpty()))
} catch (exception: ReferralNotFound) {
respond(HttpStatusCode.NotFound, ReferralErrorDto(exception.message.orEmpty()))
} catch (exception: ReferralWindowExpired) {
respond(HttpStatusCode.UnprocessableEntity, ReferralErrorDto(exception.message.orEmpty()))
} catch (exception: ReferralConflict) {
respond(HttpStatusCode.Conflict, ReferralErrorDto(exception.message.orEmpty()))
} catch (exception: ReferralException) {
respond(HttpStatusCode.UnprocessableEntity, ReferralErrorDto(exception.message.orEmpty()))
}
}
@@ -0,0 +1,231 @@
package com.osglab.account.features.referrals.services
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.referrals.domain.InviteCodeGenerator
import com.osglab.account.features.referrals.domain.InvalidReferralRequest
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralBindingRules
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.domain.ReferralConflict
import com.osglab.account.features.referrals.domain.ReferralNotFound
import com.osglab.account.features.referrals.domain.ReferralWindowExpired
import com.osglab.account.features.referrals.domain.SecureInviteCodeGenerator
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Locale
import java.util.UUID
fun interface UserRegistrationTimeProvider {
suspend fun registeredAt(userId: UUID): Instant?
}
data class ReferralRiskAssessment(
val identityFingerprint: String,
val restricted: Boolean,
)
fun interface ReferralRiskPort {
suspend fun assess(userId: UUID): ReferralRiskAssessment?
}
typealias ReferralRiskIdentity = ReferralRiskAssessment
typealias ReferralRiskProvider = ReferralRiskPort
data class ReferralProfile(
val code: ReferralCode?,
val binding: ReferralBinding?,
)
/**
* Public referral boundary. Binding and code creation remain transactionally
* consistent even when callers retry after a timeout.
*/
interface ReferralOperations {
suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode
suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode
suspend fun bind(inviteeUserId: UUID, rawCode: String): ReferralBinding
suspend fun getProfile(userId: UUID): ReferralProfile
suspend fun listActiveCampaigns(): List<ReferralCampaign>
suspend fun listInvited(userId: UUID, limit: Int = 50): List<ReferralBinding>
}
class ReferralService(
private val transactions: BillingTransactionRunner,
private val registrationTimeProvider: UserRegistrationTimeProvider,
private val riskProvider: ReferralRiskProvider,
private val bindingWindow: Duration,
private val codeGenerator: InviteCodeGenerator = SecureInviteCodeGenerator(),
private val clock: Clock = Clock.systemUTC(),
private val newId: () -> UUID = UUID::randomUUID,
) : ReferralOperations {
init {
require(!bindingWindow.isNegative && !bindingWindow.isZero) {
"Referral binding window must be positive"
}
}
override suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode {
return getOrCreateCode(ownerUserId, campaignId = null)
}
override suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode {
val ownerIdentity = requireEligibleIdentity(ownerUserId)
return transactions.inTransaction { unit ->
val now = clock.instant()
val campaign = if (campaignId == null) {
unit.referrals.listActiveCampaigns(now).firstOrNull()
?: throw ReferralNotFound("No active referral campaign exists")
} else {
unit.referrals.findCampaign(campaignId)
?: throw ReferralNotFound("Referral campaign does not exist")
}
if (!campaign.isActive(now)) throw ReferralNotFound("Referral campaign is not active")
unit.referrals.findCodeByOwner(ownerUserId, campaign.id)?.let {
return@inTransaction it
}
repeat(MAX_CODE_ATTEMPTS) {
val candidate = ReferralCode(
id = newId(),
ownerUserId = ownerUserId,
ownerIdentityFingerprint = ownerIdentity.identityFingerprint,
code = codeGenerator.generate(),
createdAt = now,
campaignId = campaign.id,
)
if (candidate.code.length < 20) {
throw IllegalStateException("Invite code generator must provide at least 120 bits")
}
if (unit.referrals.insertCodeIfAbsent(candidate)) {
return@inTransaction candidate
}
unit.referrals.lockCodeByOwner(ownerUserId, campaign.id)?.let {
return@inTransaction it
}
}
throw IllegalStateException("Unable to allocate a unique referral code")
}
}
override suspend fun bind(inviteeUserId: UUID, rawCode: String): ReferralBinding {
val code = normalizeCode(rawCode)
val existing = transactions.inTransaction { unit ->
val binding = unit.referrals.findBinding(inviteeUserId)
?: return@inTransaction null
val existingCode = unit.referrals.findCode(code)
if (existingCode?.id == binding.codeId) binding
else throw ReferralConflict("This account is already bound to another inviter")
}
if (existing != null) return existing
val inviteeIdentity = requireEligibleIdentity(inviteeUserId)
val registeredAt = registrationTimeProvider.registeredAt(inviteeUserId)
?: throw ReferralNotFound("Registration time is unavailable")
val now = clock.instant()
return transactions.inTransaction { unit ->
unit.referrals.findBinding(inviteeUserId)?.let { existing ->
val existingCode = unit.referrals.findCode(code)
if (existingCode?.id == existing.codeId) return@inTransaction existing
throw ReferralConflict("This account is already bound to another inviter")
}
val referralCode = unit.referrals.findCode(code)
?: throw ReferralNotFound("Referral code does not exist")
val campaign = referralCode.campaignId
?.let(unit.referrals::findCampaign)
if (campaign != null && !campaign.isActive(now)) {
throw ReferralNotFound("Referral campaign is not active")
}
val effectiveWindow = campaign
?.bindingWindowSeconds
?.let(Duration::ofSeconds)
?: bindingWindow
if (!ReferralBindingRules.isWithinWindow(registeredAt, now, effectiveWindow)) {
throw ReferralWindowExpired()
}
if (ReferralBindingRules.isSelfReferral(
inviteeUserId,
inviteeIdentity.identityFingerprint,
referralCode,
)
) {
throw ReferralConflict("Self-referral is not allowed")
}
val binding = ReferralBinding(
id = newId(),
inviterUserId = referralCode.ownerUserId,
inviteeUserId = inviteeUserId,
codeId = referralCode.id,
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
campaignId = referralCode.campaignId,
)
if (unit.referrals.insertBindingIfAbsent(binding)) {
binding
} else {
val concurrent = unit.referrals.lockBinding(inviteeUserId)
?: throw ReferralConflict("Referral binding changed concurrently")
if (concurrent.codeId == referralCode.id) concurrent
else throw ReferralConflict("This account is already bound to another inviter")
}
}
}
override suspend fun getProfile(userId: UUID): ReferralProfile =
transactions.inTransaction { unit ->
ReferralProfile(
code = unit.referrals.findCodeByOwner(userId),
binding = unit.referrals.findBinding(userId),
)
}
override suspend fun listActiveCampaigns(): List<ReferralCampaign> =
transactions.inTransaction { it.referrals.listActiveCampaigns(clock.instant()) }
override suspend fun listInvited(userId: UUID, limit: Int): List<ReferralBinding> {
if (limit !in 1..100) {
throw InvalidReferralRequest("Referral limit must be between 1 and 100")
}
return transactions.inTransaction {
it.referrals.listBindingsByInviter(userId, limit)
}
}
private suspend fun requireEligibleIdentity(userId: UUID): ReferralRiskAssessment {
val identity = riskProvider.assess(userId)
?: throw ReferralNotFound("Account identity is unavailable")
if (identity.restricted) {
throw ReferralConflict("This account is not eligible for referral rewards")
}
val normalizedFingerprint = identity.identityFingerprint
.trim()
.lowercase(Locale.ROOT)
if (normalizedFingerprint.length != IDENTITY_FINGERPRINT_LENGTH ||
normalizedFingerprint.any { it !in '0'..'9' && it !in 'a'..'f' }
) {
throw ReferralNotFound("Account identity is unavailable")
}
return identity.copy(identityFingerprint = normalizedFingerprint)
}
private fun normalizeCode(value: String): String {
val normalized = value.trim()
if (normalized.length !in 20..32 ||
normalized.any { !it.isLetterOrDigit() && it != '-' && it != '_' }
) {
throw ReferralNotFound("Referral code does not exist")
}
return normalized
}
private companion object {
const val MAX_CODE_ATTEMPTS = 8
const val IDENTITY_FINGERPRINT_LENGTH = 64
}
}
@@ -0,0 +1,14 @@
-----BEGIN CERTIFICATE-----
MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
oyFraWVIyd/dganmrduC1bmTBGwD
-----END CERTIFICATE-----
+61
View File
@@ -0,0 +1,61 @@
ktor:
application:
modules:
- com.osglab.account.ApplicationKt.module
deployment:
host: 0.0.0.0
port: "$PORT:8080"
app:
environment: "$APP_ENV:development"
publicBaseUrl: "$PUBLIC_BASE_URL:https://account.osglab.com"
inviteBaseUrl: "$INVITE_BASE_URL:https://osglab.com/i"
appStoreUrl: "$APP_STORE_URL:https://apps.apple.com/app/id0000000000"
database:
jdbcUrl: "$DATABASE_URL:jdbc:mysql://localhost:3306/osg_account?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true"
username: "$DATABASE_USER:osg_account"
password: "$DATABASE_PASSWORD"
migrationUsername: "$DATABASE_MIGRATION_USER:"
migrationPassword: "$DATABASE_MIGRATION_PASSWORD:"
maximumPoolSize: "$DATABASE_POOL_SIZE:10"
session:
issuer: "$JWT_ISSUER:https://account.osglab.com"
audience: "$JWT_AUDIENCE:osgkeyboard-ios"
secret: "$JWT_SECRET"
accessMinutes: "$ACCESS_TOKEN_MINUTES:15"
refreshDays: "$REFRESH_TOKEN_DAYS:30"
gatewayGrantDays: "$GATEWAY_GRANT_DAYS:30"
encryption:
keyBase64: "$FIELD_ENCRYPTION_KEY"
antiAbuse:
identityHmacKeyBase64: "$IDENTITY_HMAC_KEY"
tombstoneRetentionDays: "$IDENTITY_TOMBSTONE_RETENTION_DAYS:365"
apple:
teamId: "$APPLE_TEAM_ID:"
keyId: "$APPLE_KEY_ID:"
clientId: "$APPLE_CLIENT_ID:com.osgkeyboard.ios"
privateKeyPem: "$APPLE_PRIVATE_KEY_PEM:"
jwksUrl: "$APPLE_JWKS_URL:https://appleid.apple.com/auth/keys"
tokenUrl: "$APPLE_TOKEN_URL:https://appleid.apple.com/auth/token"
revokeUrl: "$APPLE_REVOKE_URL:https://appleid.apple.com/auth/revoke"
credits:
signupTrial: "$SIGNUP_TRIAL_CREDITS:1000"
referralInviter: "$REFERRAL_INVITER_CREDITS:3000"
referralInvitee: "$REFERRAL_INVITEE_CREDITS:3000"
referralBindingDays: "$REFERRAL_BINDING_DAYS:7"
providers:
volcengine:
endpoint: "$VOLCENGINE_ASR_ENDPOINT:wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"
appId: "$VOLCENGINE_APP_ID:"
accessToken: "$VOLCENGINE_ACCESS_TOKEN:"
apiKey: "$VOLCENGINE_API_KEY:"
resourceId: "$VOLCENGINE_RESOURCE_ID:volc.seedasr.sauc.duration"
deepseek:
endpoint: "$DEEPSEEK_ENDPOINT:https://api.deepseek.com/v1"
apiKey: "$DEEPSEEK_API_KEY:"
model: "$DEEPSEEK_MODEL:deepseek-v4-flash"
integrity:
enforceDeviceCheck: "$ENFORCE_DEVICE_CHECK:false"
enforceAppAttest: "$ENFORCE_APP_ATTEST:false"
appleEnvironment: "$APPLE_INTEGRITY_ENVIRONMENT:development"
challengeLifetimeSeconds: "$APP_ATTEST_CHALLENGE_TTL_SECONDS:300"
@@ -0,0 +1,47 @@
CREATE TABLE accounts (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
apple_sub VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at TIMESTAMP(6) NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_accounts_apple_sub (apple_sub)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE apple_credentials (
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
encrypted_refresh_token MEDIUMTEXT NOT NULL,
created_at TIMESTAMP(6) NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (account_id),
CONSTRAINT fk_apple_credentials_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE sessions (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
family_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
refresh_token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
replaced_by_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
created_at TIMESTAMP(6) NOT NULL,
expires_at TIMESTAMP(6) NOT NULL,
revoked_at TIMESTAMP(6) NULL,
reuse_detected_at TIMESTAMP(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_sessions_refresh_token_hash (refresh_token_hash),
KEY ix_sessions_account_id (account_id),
KEY ix_sessions_family_id (family_id),
KEY ix_sessions_account_active (account_id, revoked_at, expires_at),
CONSTRAINT fk_sessions_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE,
CONSTRAINT fk_sessions_replaced_by
FOREIGN KEY (replaced_by_id) REFERENCES sessions (id) ON DELETE SET NULL,
CONSTRAINT chk_sessions_expiry CHECK (expires_at > created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE apple_event_receipts (
event_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
event_type VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
received_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,352 @@
CREATE TABLE credit_accounts (
user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
balance BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (user_id),
CONSTRAINT chk_credit_accounts_non_negative CHECK (balance >= 0)
) ENGINE = InnoDB;
CREATE TABLE credit_rate_versions (
id CHAR(36) NOT NULL,
kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
provider VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
model VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
effective_from DATETIME(6) NOT NULL,
effective_until DATETIME(6) NULL,
asr_credits_numerator BIGINT NULL,
asr_millis_denominator BIGINT NULL,
input_credits_numerator BIGINT NULL,
input_tokens_denominator BIGINT NULL,
output_credits_numerator BIGINT NULL,
output_tokens_denominator BIGINT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_credit_rates_effective (
kind, provider, model, effective_from
),
INDEX idx_credit_rates_lookup (kind, provider, model, effective_from),
CONSTRAINT chk_credit_rates_interval
CHECK (effective_until IS NULL OR effective_until > effective_from),
CONSTRAINT chk_credit_rates_shape CHECK (
(
kind = 'ASR'
AND asr_credits_numerator > 0
AND asr_millis_denominator > 0
AND input_credits_numerator IS NULL
AND input_tokens_denominator IS NULL
AND output_credits_numerator IS NULL
AND output_tokens_denominator IS NULL
)
OR
(
kind = 'LLM'
AND asr_credits_numerator IS NULL
AND asr_millis_denominator IS NULL
AND input_credits_numerator > 0
AND input_tokens_denominator > 0
AND output_credits_numerator > 0
AND output_tokens_denominator > 0
)
)
) ENGINE = InnoDB;
CREATE TABLE credit_reservations (
id CHAR(36) NOT NULL,
user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
rate_version_id CHAR(36) NOT NULL,
provider VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
model VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
usage_kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
estimated_asr_millis BIGINT NULL,
estimated_input_tokens BIGINT NULL,
estimated_output_tokens BIGINT NULL,
actual_asr_millis BIGINT NULL,
actual_input_tokens BIGINT NULL,
actual_output_tokens BIGINT NULL,
reserved_credits BIGINT NOT NULL,
settled_credits BIGINT NULL,
status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
managed_call BOOLEAN NOT NULL,
reserve_idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
settle_idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
release_idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
refund_idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_credit_reservation_reserve (user_id, reserve_idempotency_key),
UNIQUE KEY uk_credit_reservation_settle (user_id, settle_idempotency_key),
UNIQUE KEY uk_credit_reservation_release (user_id, release_idempotency_key),
UNIQUE KEY uk_credit_reservation_refund (user_id, refund_idempotency_key),
INDEX idx_credit_reservations_user_status (user_id, status),
CONSTRAINT fk_credit_reservations_rate
FOREIGN KEY (rate_version_id) REFERENCES credit_rate_versions (id) ON DELETE RESTRICT,
CONSTRAINT chk_credit_reservations_positive CHECK (reserved_credits > 0),
CONSTRAINT chk_credit_reservations_estimated_usage CHECK (
(
usage_kind = 'ASR'
AND estimated_asr_millis >= 0
AND estimated_input_tokens IS NULL
AND estimated_output_tokens IS NULL
)
OR
(
usage_kind = 'LLM'
AND estimated_asr_millis IS NULL
AND estimated_input_tokens >= 0
AND estimated_output_tokens >= 0
)
),
CONSTRAINT chk_credit_reservations_state CHECK (
(
status = 'RESERVED'
AND actual_asr_millis IS NULL
AND actual_input_tokens IS NULL
AND actual_output_tokens IS NULL
AND settled_credits IS NULL
AND settle_idempotency_key IS NULL
AND release_idempotency_key IS NULL
AND refund_idempotency_key IS NULL
)
OR
(
status = 'SETTLED'
AND settled_credits >= 0
AND settle_idempotency_key IS NOT NULL
AND release_idempotency_key IS NULL
AND refund_idempotency_key IS NULL
AND (
(
usage_kind = 'ASR'
AND actual_asr_millis >= 0
AND actual_input_tokens IS NULL
AND actual_output_tokens IS NULL
)
OR
(
usage_kind = 'LLM'
AND actual_asr_millis IS NULL
AND actual_input_tokens >= 0
AND actual_output_tokens >= 0
)
)
)
OR
(
status = 'RELEASED'
AND actual_asr_millis IS NULL
AND actual_input_tokens IS NULL
AND actual_output_tokens IS NULL
AND settled_credits IS NULL
AND settle_idempotency_key IS NULL
AND release_idempotency_key IS NOT NULL
AND refund_idempotency_key IS NULL
)
OR
(
status = 'REFUNDED'
AND settled_credits >= 0
AND settle_idempotency_key IS NOT NULL
AND release_idempotency_key IS NULL
AND refund_idempotency_key IS NOT NULL
AND (
(
usage_kind = 'ASR'
AND actual_asr_millis >= 0
AND actual_input_tokens IS NULL
AND actual_output_tokens IS NULL
)
OR
(
usage_kind = 'LLM'
AND actual_asr_millis IS NULL
AND actual_input_tokens >= 0
AND actual_output_tokens >= 0
)
)
)
)
) ENGINE = InnoDB;
CREATE TABLE referral_campaigns (
id CHAR(36) NOT NULL,
name VARCHAR(100) NOT NULL,
starts_at DATETIME(6) NOT NULL,
ends_at DATETIME(6) NULL,
binding_window_seconds BIGINT NOT NULL,
inviter_reward_credits BIGINT NOT NULL,
invitee_reward_credits BIGINT NOT NULL,
max_rewarded_bindings BIGINT NULL,
budget_credits BIGINT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
INDEX idx_referral_campaigns_active (enabled, starts_at, ends_at),
CONSTRAINT chk_referral_campaign_interval
CHECK (ends_at IS NULL OR ends_at > starts_at),
CONSTRAINT chk_referral_campaign_values CHECK (
binding_window_seconds > 0
AND inviter_reward_credits > 0
AND invitee_reward_credits > 0
AND inviter_reward_credits <= 9223372036854775807 - invitee_reward_credits
AND (max_rewarded_bindings IS NULL OR max_rewarded_bindings > 0)
AND (
budget_credits IS NULL
OR (
budget_credits >= inviter_reward_credits
AND budget_credits - inviter_reward_credits >= invitee_reward_credits
)
)
)
) ENGINE = InnoDB;
CREATE TABLE referral_campaign_budgets (
campaign_id CHAR(36) NOT NULL,
rewarded_bindings BIGINT NOT NULL DEFAULT 0,
spent_credits BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (campaign_id),
CONSTRAINT fk_referral_campaign_budget_campaign
FOREIGN KEY (campaign_id) REFERENCES referral_campaigns (id) ON DELETE CASCADE,
CONSTRAINT chk_referral_campaign_budget_non_negative
CHECK (rewarded_bindings >= 0 AND spent_credits >= 0)
) ENGINE = InnoDB;
INSERT INTO referral_campaigns (
id, name, starts_at, binding_window_seconds,
inviter_reward_credits, invitee_reward_credits, enabled
) VALUES (
'00000000-0000-0000-0000-000000000001',
'Default referral campaign',
'1970-01-01 00:00:00.000000',
604800,
3000,
3000,
TRUE
);
INSERT INTO referral_campaign_budgets (campaign_id)
VALUES ('00000000-0000-0000-0000-000000000001');
CREATE TABLE referral_codes (
id CHAR(36) NOT NULL,
owner_user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
campaign_id CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
code VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_referral_codes_owner_campaign (owner_user_id, campaign_id),
UNIQUE KEY uk_referral_codes_code (code),
CONSTRAINT fk_referral_codes_campaign
FOREIGN KEY (campaign_id) REFERENCES referral_campaigns (id) ON DELETE RESTRICT
) ENGINE = InnoDB;
CREATE TABLE referral_bindings (
id CHAR(36) NOT NULL,
inviter_user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
invitee_user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
code_id CHAR(36) NOT NULL,
campaign_id CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
bound_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
rewarded_at DATETIME(6) NULL,
reward_settlement_id CHAR(36) NULL,
reward_status VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin
NOT NULL DEFAULT 'PENDING',
PRIMARY KEY (id),
UNIQUE KEY uk_referral_bindings_invitee (invitee_user_id),
UNIQUE KEY uk_referral_bindings_settlement (reward_settlement_id),
INDEX idx_referral_bindings_inviter (inviter_user_id),
CONSTRAINT fk_referral_bindings_code
FOREIGN KEY (code_id) REFERENCES referral_codes (id) ON DELETE RESTRICT,
CONSTRAINT fk_referral_bindings_campaign
FOREIGN KEY (campaign_id) REFERENCES referral_campaigns (id) ON DELETE RESTRICT,
CONSTRAINT chk_referral_bindings_no_self CHECK (inviter_user_id <> invitee_user_id),
CONSTRAINT chk_referral_bindings_reward_pair CHECK (
(
reward_status IN ('PENDING', 'INELIGIBLE_BUDGET')
AND rewarded_at IS NULL
AND reward_settlement_id IS NULL
)
OR
(
reward_status = 'REWARDED'
AND rewarded_at IS NOT NULL
AND reward_settlement_id IS NOT NULL
)
)
) ENGINE = InnoDB;
CREATE TABLE credit_usage_records (
id CHAR(36) NOT NULL,
reservation_id CHAR(36) NOT NULL,
user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
rate_version_id CHAR(36) NOT NULL,
usage_kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
asr_millis BIGINT NULL,
input_tokens BIGINT NULL,
output_tokens BIGINT NULL,
charged_credits BIGINT NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_credit_usage_reservation (reservation_id),
INDEX idx_credit_usage_user_created (user_id, created_at),
CONSTRAINT fk_credit_usage_reservation
FOREIGN KEY (reservation_id) REFERENCES credit_reservations (id) ON DELETE CASCADE,
CONSTRAINT fk_credit_usage_rate
FOREIGN KEY (rate_version_id) REFERENCES credit_rate_versions (id) ON DELETE RESTRICT,
CONSTRAINT fk_credit_usage_account
FOREIGN KEY (user_id) REFERENCES credit_accounts (user_id) ON DELETE CASCADE,
CONSTRAINT chk_credit_usage_credits CHECK (charged_credits >= 0),
CONSTRAINT chk_credit_usage_shape CHECK (
(
usage_kind = 'ASR'
AND asr_millis >= 0
AND input_tokens IS NULL
AND output_tokens IS NULL
)
OR
(
usage_kind = 'LLM'
AND asr_millis IS NULL
AND input_tokens >= 0
AND output_tokens >= 0
)
)
) ENGINE = InnoDB;
CREATE TABLE credit_ledger (
id CHAR(36) NOT NULL,
user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
entry_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
amount_delta BIGINT NOT NULL,
balance_after BIGINT NOT NULL,
idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
reference_id CHAR(36) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_credit_ledger_idempotency (user_id, idempotency_key),
INDEX idx_credit_ledger_user_created (user_id, created_at, id),
INDEX idx_credit_ledger_reference (reference_id),
CONSTRAINT chk_credit_ledger_balance CHECK (balance_after >= 0),
CONSTRAINT chk_credit_ledger_key_length
CHECK (CHAR_LENGTH(idempotency_key) BETWEEN 8 AND 128),
CONSTRAINT chk_credit_ledger_type CHECK (
entry_type IN (
'SIGNUP_TRIAL',
'MANUAL_GRANT',
'USAGE_RESERVE',
'USAGE_SETTLE',
'USAGE_RELEASE',
'USAGE_REFUND',
'REFERRAL_INVITER',
'REFERRAL_INVITEE',
'STOREKIT_PURCHASE',
'SUBSCRIPTION_GRANT'
)
)
) ENGINE = InnoDB;
-- The application repository exposes append-only operations for ledger, rate,
-- and usage tables. Production additionally grants the runtime user SELECT and
-- INSERT only on these tables. Avoiding stored triggers keeps migrations
-- compatible with binary-logged MySQL without granting global SUPER privilege.
@@ -0,0 +1,76 @@
CREATE TABLE provider_requests (
request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
provider_id VARCHAR(64) NOT NULL,
capability VARCHAR(32) NOT NULL,
status VARCHAR(24) NOT NULL,
provider_request_id VARCHAR(128) NULL,
server_duration_millis BIGINT UNSIGNED NULL,
error_code VARCHAR(96) NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
completed_at TIMESTAMP(6) NULL,
PRIMARY KEY (request_id),
INDEX idx_provider_requests_account_created (account_id, created_at),
INDEX idx_provider_requests_provider_created (provider_id, created_at),
CONSTRAINT fk_provider_requests_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE usage_records (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
meter VARCHAR(32) NOT NULL,
units BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uq_usage_records_request_meter (request_id, meter),
CONSTRAINT fk_usage_records_provider_request
FOREIGN KEY (request_id) REFERENCES provider_requests (request_id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE gateway_grants (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
idempotency_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
expires_at TIMESTAMP(6) NOT NULL,
revoked_at TIMESTAMP(6) NULL,
created_at TIMESTAMP(6) NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_gateway_grants_account_idempotency (account_id, idempotency_key),
INDEX idx_gateway_grants_account_active (account_id, revoked_at, expires_at),
CONSTRAINT fk_gateway_grants_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE gateway_grant_scopes (
grant_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
capability VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
PRIMARY KEY (grant_id, capability),
CONSTRAINT fk_gateway_grant_scopes_grant
FOREIGN KEY (grant_id) REFERENCES gateway_grants (id) ON DELETE CASCADE,
CONSTRAINT chk_gateway_grant_scope
CHECK (capability IN ('POLISH', 'AI', 'AGENT', 'ASR'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE gateway_refresh_tokens (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
grant_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
family_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
replaced_by_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
rotation_idempotency_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
expires_at TIMESTAMP(6) NOT NULL,
revoked_at TIMESTAMP(6) NULL,
reuse_detected_at TIMESTAMP(6) NULL,
created_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_gateway_refresh_token_hash (token_hash),
INDEX idx_gateway_refresh_grant (grant_id),
INDEX idx_gateway_refresh_family (family_id),
CONSTRAINT fk_gateway_refresh_grant
FOREIGN KEY (grant_id) REFERENCES gateway_grants (id) ON DELETE CASCADE,
CONSTRAINT fk_gateway_refresh_replacement
FOREIGN KEY (replaced_by_id) REFERENCES gateway_refresh_tokens (id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,51 @@
CREATE TABLE devicecheck_trial_claims (
device_token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (device_token_hash),
KEY ix_devicecheck_trial_account (account_id),
CONSTRAINT fk_devicecheck_trial_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE,
CONSTRAINT chk_devicecheck_trial_status
CHECK (status IN ('RESERVED', 'APPLE_MARKED', 'COMPLETED', 'REJECTED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE app_attest_challenges (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
key_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
purpose VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
challenge_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
expires_at DATETIME(6) NOT NULL,
consumed_at DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
KEY ix_app_attest_challenge_expiry (expires_at),
KEY ix_app_attest_challenge_account (account_id),
CONSTRAINT fk_app_attest_challenge_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE,
CONSTRAINT chk_app_attest_challenge_purpose
CHECK (purpose IN ('ATTESTATION', 'ASSERTION')),
CONSTRAINT chk_app_attest_challenge_status
CHECK (status IN ('ISSUED', 'CONSUMED', 'EXPIRED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE app_attest_keys (
key_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
public_key_base64 VARCHAR(512) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
receipt_base64 MEDIUMTEXT CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
sign_counter BIGINT NOT NULL DEFAULT 0,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (key_id),
KEY ix_app_attest_keys_account (account_id),
CONSTRAINT fk_app_attest_keys_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE SET NULL,
CONSTRAINT chk_app_attest_counter_non_negative CHECK (sign_counter >= 0),
CONSTRAINT chk_app_attest_key_status CHECK (status IN ('ACTIVE', 'REVOKED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,113 @@
ALTER TABLE accounts
ADD COLUMN identity_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
ADD COLUMN anti_abuse_restricted BOOLEAN NOT NULL DEFAULT FALSE,
ADD UNIQUE KEY uq_accounts_identity_fingerprint (identity_fingerprint);
CREATE TABLE account_identity_tombstones (
identity_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
deleted_at DATETIME(6) NOT NULL,
expires_at DATETIME(6) NOT NULL,
PRIMARY KEY (identity_fingerprint),
INDEX ix_account_tombstones_expiry (expires_at),
CONSTRAINT chk_account_tombstone_interval CHECK (expires_at > deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE apple_revocation_outbox (
id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
encrypted_refresh_token MEDIUMTEXT NULL,
created_at DATETIME(6) NOT NULL,
next_attempt_at DATETIME(6) NOT NULL,
attempt_count INT NOT NULL DEFAULT 0,
completed_at DATETIME(6) NULL,
PRIMARY KEY (id),
INDEX ix_apple_revocation_pending (completed_at, next_attempt_at),
CONSTRAINT chk_apple_revocation_attempts CHECK (attempt_count >= 0),
CONSTRAINT chk_apple_revocation_token_lifecycle CHECK (
(completed_at IS NULL AND encrypted_refresh_token IS NOT NULL)
OR
(completed_at IS NOT NULL AND encrypted_refresh_token IS NULL)
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- Existing V2/V3 tables predated account foreign keys. Remove only already-orphaned
-- mutable state before enforcing referential integrity. The immutable credit ledger
-- intentionally remains pseudonymized by its now-unmapped random account UUID.
DELETE ur
FROM usage_records ur
LEFT JOIN provider_requests pr ON pr.request_id = ur.request_id
WHERE pr.request_id IS NULL;
DELETE rb
FROM referral_bindings rb
LEFT JOIN accounts inviter ON inviter.id = rb.inviter_user_id
LEFT JOIN accounts invitee ON invitee.id = rb.invitee_user_id
LEFT JOIN referral_codes rc ON rc.id = rb.code_id
LEFT JOIN accounts code_owner ON code_owner.id = rc.owner_user_id
WHERE inviter.id IS NULL
OR invitee.id IS NULL
OR rc.id IS NULL
OR code_owner.id IS NULL
OR rc.owner_user_id <> rb.inviter_user_id;
DELETE rc
FROM referral_codes rc
LEFT JOIN accounts a ON a.id = rc.owner_user_id
WHERE a.id IS NULL;
DELETE cr
FROM credit_reservations cr
LEFT JOIN accounts a ON a.id = cr.user_id
WHERE a.id IS NULL;
DELETE ca
FROM credit_accounts ca
LEFT JOIN accounts a ON a.id = ca.user_id
WHERE a.id IS NULL;
DELETE ur
FROM usage_records ur
JOIN provider_requests pr ON pr.request_id = ur.request_id
LEFT JOIN accounts a ON a.id = pr.account_id
WHERE a.id IS NULL;
DELETE pr
FROM provider_requests pr
LEFT JOIN accounts a ON a.id = pr.account_id
WHERE a.id IS NULL;
DELETE gg
FROM gateway_grants gg
LEFT JOIN accounts a ON a.id = gg.account_id
WHERE a.id IS NULL;
ALTER TABLE credit_accounts
ADD CONSTRAINT fk_credit_accounts_user
FOREIGN KEY (user_id) REFERENCES accounts (id) ON DELETE CASCADE;
ALTER TABLE credit_reservations
ADD CONSTRAINT fk_credit_reservations_user
FOREIGN KEY (user_id) REFERENCES accounts (id) ON DELETE CASCADE;
ALTER TABLE referral_bindings
DROP FOREIGN KEY fk_referral_bindings_code;
ALTER TABLE referral_bindings
ADD CONSTRAINT fk_referral_bindings_code
FOREIGN KEY (code_id) REFERENCES referral_codes (id) ON DELETE CASCADE,
ADD CONSTRAINT fk_referral_bindings_inviter
FOREIGN KEY (inviter_user_id) REFERENCES accounts (id) ON DELETE CASCADE,
ADD CONSTRAINT fk_referral_bindings_invitee
FOREIGN KEY (invitee_user_id) REFERENCES accounts (id) ON DELETE CASCADE;
ALTER TABLE referral_codes
ADD COLUMN owner_identity_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
ADD CONSTRAINT fk_referral_codes_owner
FOREIGN KEY (owner_user_id) REFERENCES accounts (id) ON DELETE CASCADE;
ALTER TABLE usage_records
DROP FOREIGN KEY fk_usage_records_provider_request;
ALTER TABLE usage_records
ADD CONSTRAINT fk_usage_records_provider_request
FOREIGN KEY (request_id) REFERENCES provider_requests (request_id) ON DELETE CASCADE;
@@ -0,0 +1,65 @@
-- Make client request IDs idempotent within an account, not globally.
ALTER TABLE usage_records
DROP FOREIGN KEY fk_usage_records_provider_request,
DROP INDEX uq_usage_records_request_meter,
ADD COLUMN account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER id;
UPDATE usage_records ur
JOIN provider_requests pr ON pr.request_id = ur.request_id
SET ur.account_id = pr.account_id;
ALTER TABLE usage_records
MODIFY account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL;
-- Normalize every legacy value before installing the binary status column and
-- stricter CHECK. Unknown historical states are held for manual review rather
-- than making a non-transactional MySQL migration fail halfway through.
UPDATE provider_requests
SET status = CASE UPPER(status)
WHEN 'SUCCEEDED' THEN 'SETTLED'
WHEN 'FAILED' THEN 'MANUAL_REVIEW'
WHEN 'CLAIMED' THEN 'CLAIMED'
WHEN 'STARTED' THEN 'STARTED'
WHEN 'SETTLEMENT_PENDING' THEN 'SETTLEMENT_PENDING'
WHEN 'SETTLED' THEN 'SETTLED'
WHEN 'RELEASED' THEN 'RELEASED'
WHEN 'MANUAL_REVIEW' THEN 'MANUAL_REVIEW'
ELSE 'MANUAL_REVIEW'
END;
ALTER TABLE provider_requests
ADD COLUMN reservation_id CHAR(36) NULL AFTER account_id,
ADD COLUMN usage_meter VARCHAR(32) NULL AFTER provider_request_id,
ADD COLUMN usage_units BIGINT UNSIGNED NULL AFTER usage_meter,
ADD COLUMN usage_input_units BIGINT UNSIGNED NULL AFTER usage_units,
ADD COLUMN usage_output_units BIGINT UNSIGNED NULL AFTER usage_input_units,
MODIFY COLUMN status VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
DROP PRIMARY KEY,
ADD PRIMARY KEY (account_id, request_id),
ADD UNIQUE KEY uq_provider_requests_reservation (reservation_id),
ADD INDEX idx_provider_requests_status_created (status, created_at),
ADD CONSTRAINT chk_provider_request_status CHECK (
status IN (
'CLAIMED',
'STARTED',
'SETTLEMENT_PENDING',
'SETTLED',
'RELEASED',
'MANUAL_REVIEW'
)
);
ALTER TABLE usage_records
ADD UNIQUE KEY uq_usage_records_account_request_meter (account_id, request_id, meter),
ADD CONSTRAINT fk_usage_records_provider_request
FOREIGN KEY (account_id, request_id)
REFERENCES provider_requests (account_id, request_id)
ON DELETE CASCADE;
-- A rotated refresh row points to its replacement. RESTRICT can block the
-- account -> grant -> refresh cascade when an account is deleted.
ALTER TABLE gateway_refresh_tokens
DROP FOREIGN KEY fk_gateway_refresh_replacement,
ADD CONSTRAINT fk_gateway_refresh_replacement
FOREIGN KEY (replaced_by_id) REFERENCES gateway_refresh_tokens (id)
ON DELETE SET NULL;
@@ -0,0 +1,49 @@
-- Initial integer-credit rate cards. Future price changes must insert a new
-- immutable version and close the previous effective interval.
INSERT INTO credit_rate_versions (
id,
kind,
provider,
model,
effective_from,
effective_until,
asr_credits_numerator,
asr_millis_denominator,
created_at
) VALUES (
'10000000-0000-0000-0000-000000000001',
'ASR',
'volcengine-sauc-v3',
'volc.seedasr.sauc.duration',
'1970-01-01 00:00:00.000000',
NULL,
1,
1000,
UTC_TIMESTAMP(6)
);
INSERT INTO credit_rate_versions (
id,
kind,
provider,
model,
effective_from,
effective_until,
input_credits_numerator,
input_tokens_denominator,
output_credits_numerator,
output_tokens_denominator,
created_at
) VALUES (
'10000000-0000-0000-0000-000000000002',
'LLM',
'deepseek',
'deepseek-v4-flash',
'1970-01-01 00:00:00.000000',
NULL,
1,
100,
2,
100,
UTC_TIMESTAMP(6)
);
@@ -0,0 +1,17 @@
{
"applinks": {
"details": [
{
"appIDs": [
"{{APPLE_APP_ID}}"
],
"components": [
{
"/": "/i/*",
"comment": "Open first-party OSG invitation links in the app"
}
]
}
]
}
}
+184
View File
@@ -0,0 +1,184 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow,noarchive">
<meta name="color-scheme" content="light dark">
<title>OSG 邀请 / Invitation</title>
<style nonce="{{CSP_NONCE}}">
:root {
color-scheme: light dark;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f5f7;
color: #1d1d1f;
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right))
max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left));
}
main {
width: min(100%, 460px);
padding: clamp(24px, 7vw, 40px);
border: 1px solid #dedee3;
border-radius: 24px;
background: #fff;
text-align: center;
box-shadow: 0 16px 48px rgb(0 0 0 / 8%);
}
.language-switcher {
display: flex;
justify-content: flex-end;
gap: 6px;
margin: -8px -8px 20px 0;
}
.language-button {
min-height: 36px;
padding: 7px 11px;
border: 1px solid #d2d2d7;
border-radius: 999px;
background: transparent;
color: inherit;
font: inherit;
font-size: .82rem;
cursor: pointer;
}
.language-button[aria-pressed="true"] {
border-color: #1d1d1f;
background: #1d1d1f;
color: #fff;
}
h1 { margin: 0 0 12px; font-size: clamp(1.65rem, 7vw, 2.25rem); line-height: 1.15; }
p { margin: 10px 0; color: #6e6e73; line-height: 1.55; }
code {
display: block;
margin: 24px 0;
padding: 17px 10px;
overflow-wrap: anywhere;
border: 1px solid #e5e5ea;
border-radius: 14px;
background: #f5f5f7;
color: #1d1d1f;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: clamp(1rem, 4.8vw, 1.35rem);
letter-spacing: .05em;
user-select: all;
}
.actions { display: grid; gap: 12px; }
.action {
display: grid;
min-height: 50px;
place-items: center;
padding: 13px 18px;
border: 1px solid transparent;
border-radius: 14px;
font: inherit;
font-weight: 650;
cursor: pointer;
text-decoration: none;
}
.primary { background: #0071e3; color: #fff; }
.secondary { border-color: #d2d2d7; background: #fff; color: #1d1d1f; }
.store-link { background: #1d1d1f; color: #fff; }
.action:focus-visible, .language-button:focus-visible {
outline: 3px solid #69aaf5;
outline-offset: 3px;
}
.hint { margin-top: 22px; font-size: .88rem; }
#copy-status { min-height: 1.4em; margin-bottom: 0; font-size: .9rem; }
[data-language] { display: none; }
html[lang="zh-CN"] [data-language="zh-CN"],
html[lang="en"] [data-language="en"] { display: inline; }
@media (prefers-color-scheme: dark) {
:root { background: #101214; color: #f4f5f7; }
main { border-color: #30343a; background: #1a1d21; box-shadow: none; }
p { color: #b5bbc4; }
code { border-color: #3a3f46; background: #282c32; color: #f4f5f7; }
.language-button { border-color: #555b64; }
.language-button[aria-pressed="true"] { border-color: #f4f5f7; background: #f4f5f7; color: #17191c; }
.secondary { border-color: #555b64; background: #282c32; color: #f4f5f7; }
.store-link { background: #f4f5f7; color: #17191c; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<main>
<nav class="language-switcher" aria-label="Language / 语言">
<button class="language-button" id="language-zh" type="button" aria-pressed="true">中文</button>
<button class="language-button" id="language-en" type="button" aria-pressed="false">English</button>
</nav>
<h1>
<span data-language="zh-CN">加入 OSG</span>
<span data-language="en">Join OSG</span>
</h1>
<p>
<span data-language="zh-CN">使用此邀请码开始体验。</span>
<span data-language="en">Use this invitation code to get started.</span>
</p>
<code id="invite-code" aria-label="邀请码 / Invitation code">{{INVITE_CODE}}</code>
<div class="actions">
<button class="action primary" id="copy-button" type="button">
<span data-language="zh-CN">复制邀请码</span>
<span data-language="en">Copy invitation code</span>
</button>
<a class="action secondary" href="{{UNIVERSAL_LINK}}" rel="noopener">
<span data-language="zh-CN">打开 App</span>
<span data-language="en">Open App</span>
</a>
<a class="action store-link" href="{{APP_STORE_URL}}" rel="noopener noreferrer">
<span data-language="zh-CN">前往 App Store</span>
<span data-language="en">Download on the App Store</span>
</a>
</div>
<p id="copy-status" role="status" aria-live="polite"></p>
<p class="hint">
<span data-language="zh-CN">若“打开 App”仍停留在浏览器,请从信息或邮件中再次轻点原邀请链接。</span>
<span data-language="en">If “Open App” stays in the browser, tap the original invitation link again from Messages or Mail.</span>
</p>
</main>
<script nonce="{{CSP_NONCE}}">
const copyButton = document.getElementById("copy-button");
const inviteCode = document.getElementById("invite-code").textContent;
const copyStatus = document.getElementById("copy-status");
const zhButton = document.getElementById("language-zh");
const enButton = document.getElementById("language-en");
let language = navigator.language.toLowerCase().startsWith("zh") ? "zh-CN" : "en";
function setLanguage(nextLanguage) {
language = nextLanguage;
document.documentElement.lang = language;
zhButton.setAttribute("aria-pressed", String(language === "zh-CN"));
enButton.setAttribute("aria-pressed", String(language === "en"));
copyStatus.textContent = "";
}
zhButton.addEventListener("click", () => setLanguage("zh-CN"));
enButton.addEventListener("click", () => setLanguage("en"));
setLanguage(language);
copyButton.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(inviteCode);
copyStatus.textContent = language === "zh-CN" ? "已复制" : "Copied";
} catch {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(document.getElementById("invite-code"));
selection.removeAllRanges();
selection.addRange(range);
copyStatus.textContent = language === "zh-CN"
? "无法自动复制,邀请码已选中"
: "Automatic copy failed; the code is selected";
}
});
</script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>{"time":"%date{ISO8601}","level":"%level","logger":"%logger{36}","message":"%replace(%msg){'[\r\n]+',' '}"}%n</pattern>
</encoder>
</appender>
<logger name="io.netty" level="WARN"/>
<logger name="org.jetbrains.exposed" level="WARN"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<root level="${LOG_LEVEL:-INFO}">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%date{ISO8601} %-5level [%thread] %logger{24} - %msg%n</pattern>
</encoder>
</appender>
<logger name="io.netty" level="WARN"/>
<logger name="org.jetbrains.exposed" level="WARN"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} %-5level [%thread] %logger{36} requestId=%X{requestId:-} - %msg%n</pattern>
</encoder>
</appender>
<logger name="io.netty" level="WARN"/>
<logger name="org.jetbrains.exposed" level="WARN"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<root level="${LOG_LEVEL:-INFO}">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
@@ -0,0 +1,19 @@
package com.osglab.account
import io.kotest.matchers.shouldBe
import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlin.test.Test
class ApplicationTest {
@Test
fun `liveness endpoint remains independent of external services`() = testApplication {
application {
routing { healthRoutes() }
}
client.get("/health/live").status shouldBe HttpStatusCode.OK
}
}
@@ -0,0 +1,84 @@
package com.osglab.account.common.api
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.common.security.SESSION_AUTH_NAME
import com.osglab.account.common.security.installSessionAuthentication
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.auth.authenticate
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
class ApiContractTest : FunSpec({
test("known API failures use the stable error envelope") {
testApplication {
application {
install(ContentNegotiation) { json(Json) }
installApiStatusPages()
routing {
get("/invalid") {
throw InvalidRequestException("Invalid input")
}
}
}
val response = client.get("/invalid")
response.status shouldBe HttpStatusCode.BadRequest
response.bodyAsText() shouldBe
"""{"error":{"code":"invalid_request","message":"Invalid input"}}"""
}
}
test("unexpected failures do not disclose exception details") {
testApplication {
application {
install(ContentNegotiation) { json(Json) }
installApiStatusPages()
routing {
get("/failure") {
error("database-password")
}
}
}
val response = client.get("/failure")
response.status shouldBe HttpStatusCode.InternalServerError
response.bodyAsText() shouldBe
"""{"error":{"code":"internal_error","message":"An internal error occurred"}}"""
}
}
test("authentication challenges use the same error envelope") {
testApplication {
application {
install(ContentNegotiation) { json(Json) }
installApiStatusPages()
installSessionAuthentication { null }
routing {
authenticate(SESSION_AUTH_NAME) {
get("/protected") {
call.respondText("unreachable")
}
}
}
}
val response = client.get("/protected")
response.status shouldBe HttpStatusCode.Unauthorized
response.bodyAsText() shouldBe
"""{"error":{"code":"unauthorized","message":"Authentication required"}}"""
}
}
})
@@ -0,0 +1,53 @@
package com.osglab.account.common.security
import com.osglab.account.config.SessionConfig
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
class SecurityPrimitivesTest : FunSpec({
test("refresh token hashes are deterministic without storing the token") {
TokenHash.sha256("secret") shouldBe
"2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b"
TokenHash.matches("secret", TokenHash.sha256("secret")) shouldBe true
TokenHash.matches("different", TokenHash.sha256("secret")) shouldBe false
}
test("AES-GCM uses unique nonces and authenticates context") {
val encryptor = FieldEncryptor(ByteArray(32) { 3 })
val first = encryptor.encrypt("apple-refresh-token", "account:1")
val second = encryptor.encrypt("apple-refresh-token", "account:1")
first shouldNotBe second
encryptor.decrypt(first, "account:1") shouldBe "apple-refresh-token"
shouldThrow<FieldDecryptionException> {
encryptor.decrypt(first, "account:2")
}
}
test("session JWT validates issuer audience signature and claims") {
val clock = Clock.fixed(Instant.parse("2026-08-15T12:00:00Z"), ZoneOffset.UTC)
val jwt = SessionJwt(
SessionConfig(
issuer = "https://issuer.example",
audience = "ios",
hmacSecret = ByteArray(32) { 9 },
accessMinutes = 15,
refreshDays = 30,
),
clock,
)
val accountId = UUID.randomUUID()
val sessionId = UUID.randomUUID()
val issued = jwt.issue(accountId, sessionId)
jwt.verify(issued.value)?.userId shouldBe accountId
jwt.verify(issued.value)?.sessionId shouldBe sessionId
jwt.verify(issued.value + "tampered") shouldBe null
}
})
@@ -0,0 +1,153 @@
package com.osglab.account.config
import io.ktor.server.config.MapApplicationConfig
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import java.util.Base64
class AppConfigTest : FunSpec({
test("test configuration can be injected without Apple client credentials") {
val config = AppConfig.from(validConfig("test"))
config.environment shouldBe Environment.TEST
config.apple.clientCredentialsAvailable shouldBe false
config.encryption.key.size shouldBe 32
}
test("production rejects placeholder secrets") {
val config = validProductionConfig().apply {
put("app.session.secret", "replace-with-secret")
}
shouldThrow<ConfigValidationException> {
AppConfig.from(config)
}
}
test("production accepts complete independent configuration") {
val config = AppConfig.from(validProductionConfig())
config.environment shouldBe Environment.PRODUCTION
config.database.username shouldBe "test"
config.database.migrationUsername shouldBe "test_migrator"
}
test("production fails fast when Apple signing credentials are missing") {
val config = validProductionConfig().apply {
put("app.apple.keyId", "")
}
shouldThrow<ConfigValidationException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "app.apple.keyId"
}
test("production rejects monitor-only integrity configuration") {
val config = validProductionConfig().apply {
put("app.providers.volcengine.apiKey", "volcengine-key")
put("app.providers.deepseek.apiKey", "deepseek-key")
put("app.integrity.enforceDeviceCheck", "false")
put("app.integrity.enforceAppAttest", "false")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "must enforce both DeviceCheck and App Attest"
}
test("production rejects provider endpoints outside the exact host allowlist") {
val config = validProductionConfig().apply {
put("app.providers.deepseek.endpoint", "https://127.0.0.1/v1")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "DeepSeek endpoint"
}
test("production requires separate migration credentials") {
val missingMigrator = validProductionConfig().apply {
put("app.database.migrationUsername", "")
}
shouldThrow<ConfigValidationException> {
AppConfig.from(missingMigrator)
}.message.orEmpty() shouldContain "app.database.migrationUsername"
val reusedPassword = validProductionConfig().apply {
put("app.database.migrationPassword", "database-password")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(reusedPassword)
}.message.orEmpty() shouldContain "passwords must be distinct"
}
test("production requires independent cryptographic secrets") {
val config = validProductionConfig().apply {
put(
"app.antiAbuse.identityHmacKeyBase64",
Base64.getEncoder().encodeToString(ByteArray(32) { 7 }),
)
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "must be distinct"
}
test("production requires exact public and App Store URLs") {
val publicUrl = validProductionConfig().apply {
put("app.publicBaseUrl", "https://account.osglab.com.evil.example")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(publicUrl)
}.message.orEmpty() shouldContain "PUBLIC_BASE_URL"
val appStoreUrl = validProductionConfig().apply {
put("app.appStoreUrl", "https://apps.apple.com/app/id0000000000")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(appStoreUrl)
}.message.orEmpty() shouldContain "APP_STORE_URL"
}
})
private fun validConfig(environment: String) = MapApplicationConfig(
"app.environment" to environment,
"app.database.jdbcUrl" to "jdbc:mysql://localhost:3306/test",
"app.database.username" to "test",
"app.database.password" to "database-password",
"app.database.migrationUsername" to "test_migrator",
"app.database.migrationPassword" to "migration-password",
"app.database.maximumPoolSize" to "4",
"app.session.issuer" to "https://issuer.example",
"app.session.audience" to "ios-app",
"app.session.secret" to "01234567890123456789012345678901",
"app.session.accessMinutes" to "15",
"app.session.refreshDays" to "30",
"app.encryption.keyBase64" to Base64.getEncoder().encodeToString(ByteArray(32) { 7 }),
"app.antiAbuse.identityHmacKeyBase64" to
Base64.getEncoder().encodeToString(ByteArray(32) { 8 }),
"app.apple.clientId" to "com.example.app",
"app.apple.jwksUrl" to "https://appleid.apple.com/auth/keys",
"app.apple.tokenUrl" to "https://appleid.apple.com/auth/token",
"app.apple.revokeUrl" to "https://appleid.apple.com/auth/revoke",
"app.integrity.enforceDeviceCheck" to "false",
"app.integrity.enforceAppAttest" to "false",
)
private fun validProductionConfig() = validConfig("production").apply {
put("app.publicBaseUrl", "https://account.osglab.com")
put("app.inviteBaseUrl", "https://osglab.com/i")
put("app.appStoreUrl", "https://apps.apple.com/app/id1234567890")
put("app.apple.teamId", APP_ATTEST_TEAM_ID)
put("app.apple.keyId", "APPLE_KEY")
put("app.apple.clientId", APP_ATTEST_BUNDLE_ID)
put("app.apple.privateKeyPem", "private-key-material")
put("app.integrity.appleEnvironment", "production")
put("app.integrity.enforceDeviceCheck", "true")
put("app.integrity.enforceAppAttest", "true")
put("app.providers.volcengine.apiKey", "volcengine-key")
put("app.providers.deepseek.apiKey", "deepseek-key")
}
@@ -0,0 +1,119 @@
package com.osglab.account.config
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import java.nio.file.Files
import java.nio.file.Path
class DeploymentConsistencyTest : FunSpec({
val root = Path.of(System.getProperty("user.dir"))
test("OpenAPI documents every mounted public route") {
val openApi = root.read("docs/openapi.yaml")
val documentedPaths = Regex("""(?m)^ (/[^:]+):\s*$""")
.findAll(openApi)
.map { it.groupValues[1] }
.toSet()
documentedPaths shouldBe EXPECTED_PUBLIC_PATHS
}
test("production Compose reuses private MySQL and hardens the application container") {
val compose = root.read("compose.yaml")
compose shouldContain "127.0.0.1:\${ACCOUNT_BIND_PORT:-18080}:8080"
compose shouldContain "external: true"
compose shouldContain "account-egress:"
compose shouldContain "user: \"10001:10001\""
compose shouldContain "read_only: true"
compose shouldContain "cap_drop:"
compose shouldContain "no-new-privileges:true"
compose shouldNotContain "image: mysql"
compose shouldNotContain "3306:3306"
compose shouldNotContain "0.0.0.0:"
}
test("container image remains non-root and read-only compatible") {
val dockerfile = root.read("Dockerfile")
dockerfile shouldContain "USER 10001:10001"
dockerfile shouldContain "ENV HOME=/tmp"
dockerfile shouldNotContain "ENTRYPOINT [\"sh\""
}
test("OpenResty proxies HTTP WebSocket invitations and both AASA paths safely") {
val openResty = root.read("deploy/openresty-account.conf")
openResty shouldContain "proxy_set_header Upgrade \$http_upgrade;"
openResty shouldContain "proxy_set_header Connection \$connection_upgrade;"
openResty shouldContain "location = /.well-known/apple-app-site-association"
openResty shouldContain "location = /apple-app-site-association"
openResty shouldContain "location ^~ /i/"
Regex("""location \^~ /i/ \{\s+access_log off;""").containsMatchIn(openResty) shouldBe true
openResty shouldNotContain "alias /www/wwwroot/osglab.com/apple-app-site-association"
}
test("CI definition is singular and leaves MySQL lifecycle to Testcontainers") {
val ci = root.read(".github/workflows/ci.yml")
Regex("""(?m)^name: CI$""").findAll(ci).count() shouldBe 1
Regex("""(?m)^jobs:$""").findAll(ci).count() shouldBe 1
ci shouldContain "docker compose -f compose.yaml config --quiet"
ci shouldContain "./gradlew --no-daemon clean test"
ci shouldContain "./gradlew --no-daemon buildFatJar"
ci shouldNotContain "3306:3306"
ci shouldNotContain "TEST_DB_"
}
test("AASA has one runtime template and no deploy-time identifier placeholder") {
val aasa = root.read("src/main/resources/invite/apple-app-site-association.json")
aasa shouldContain "\"{{APPLE_APP_ID}}\""
aasa shouldContain "\"/i/*\""
Files.exists(root.resolve("deploy/apple-app-site-association")) shouldBe false
}
})
private fun Path.read(relativePath: String): String =
Files.readString(resolve(relativePath))
private val EXPECTED_PUBLIC_PATHS = setOf(
"/health",
"/health/live",
"/health/ready",
"/v1/auth/apple",
"/v1/auth/refresh",
"/v1/auth/logout",
"/v1/account",
"/v1/apple/events",
"/v1/credits/balance",
"/v1/credits/ledger",
"/v1/credits/rates",
"/v1/credits/reservations",
"/v1/credits/reservations/{reservationId}",
"/v1/credits/reservations/{reservationId}/settle",
"/v1/credits/reservations/{reservationId}/release",
"/v1/credits/reservations/{reservationId}/refund",
"/v1/referrals",
"/v1/referrals/me",
"/v1/referrals/code",
"/v1/referrals/redeem",
"/v1/referrals/bind",
"/v1/referrals/campaigns",
"/v1/integrity/challenges",
"/v1/integrity/attest",
"/v1/integrity/assert",
"/v1/gateway/catalog",
"/v1/gateway/grants",
"/v1/gateway/grants/refresh",
"/v1/gateway/grants/{grantId}",
"/v1/gateway/llm/{capability}",
"/v1/gateway/asr",
"/v1/gateway/asr/sessions",
"/v1/gateway/asr/sessions/{sessionId}/stream",
"/.well-known/apple-app-site-association",
"/apple-app-site-association",
"/i/{code}",
)
@@ -0,0 +1,171 @@
package com.osglab.account.features.account
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.config.AntiAbuseConfig
import com.osglab.account.features.auth.AppleTokenClient
import com.osglab.account.features.auth.AppleTokenExchange
import com.osglab.account.features.auth.AppleClientUnavailableException
import com.osglab.account.features.auth.appleRefreshContext
import io.kotest.core.spec.style.FunSpec
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.util.UUID
class AccountServiceTest : FunSpec({
test("account deletion commits locally before reliably revoking the Apple token") {
val accountId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
val events = mutableListOf<String>()
val repository = RecordingAccountRepository(
AccountRecord(
id = accountId,
identityFingerprint = "a".repeat(64),
antiAbuseRestricted = false,
encryptedAppleRefreshToken = encryptor.encrypt(
"apple-refresh",
appleRefreshContext(accountId),
),
createdAt = Instant.EPOCH,
),
events,
)
val appleClient = RecordingAppleTokenClient(events)
val processor = AppleRevocationOutboxProcessor(repository, appleClient, encryptor)
val service = AccountService(
repository,
encryptor,
AntiAbuseConfig(ByteArray(32) { 9 }, 365),
processor,
AccountReauthenticator { _, _ -> "apple-refresh" },
)
service.delete(accountId, REAUTH_PROOF)
appleClient.revokedToken shouldBe "apple-refresh"
repository.deleted shouldBe true
events shouldBe listOf("local-delete", "apple-revoke", "outbox-complete")
}
test("Apple outage never rolls back local deletion and leaves durable outbox work") {
val accountId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
val events = mutableListOf<String>()
val repository = RecordingAccountRepository(
AccountRecord(
id = accountId,
identityFingerprint = "b".repeat(64),
antiAbuseRestricted = false,
encryptedAppleRefreshToken = encryptor.encrypt(
"apple-refresh",
appleRefreshContext(accountId),
),
createdAt = Instant.EPOCH,
),
events,
)
val appleClient = RecordingAppleTokenClient(events, unavailable = true)
val service = AccountService(
repository,
encryptor,
AntiAbuseConfig(ByteArray(32) { 9 }, 365),
AppleRevocationOutboxProcessor(repository, appleClient, encryptor),
AccountReauthenticator { _, _ -> "apple-refresh" },
)
service.delete(accountId, REAUTH_PROOF)
repository.deleted shouldBe true
repository.pendingCount shouldBe 1
}
test("account deletion requires recent matching Apple credentials") {
val accountId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
val repository = RecordingAccountRepository(
AccountRecord(
id = accountId,
identityFingerprint = "c".repeat(64),
antiAbuseRestricted = false,
encryptedAppleRefreshToken = null,
createdAt = Instant.EPOCH,
),
mutableListOf(),
)
val appleClient = RecordingAppleTokenClient(mutableListOf())
val service = AccountService(
repository,
encryptor,
AntiAbuseConfig(ByteArray(32) { 9 }, 365),
AppleRevocationOutboxProcessor(repository, appleClient, encryptor),
AccountReauthenticator { _, _ -> throw UnauthorizedException() },
)
shouldThrow<UnauthorizedException> {
service.delete(accountId, REAUTH_PROOF)
}
repository.deleted shouldBe false
}
})
private val REAUTH_PROOF = AppleReauthenticationProof(
identityToken = "identity-token",
authorizationCode = "authorization-code",
nonce = "nonce",
)
private class RecordingAccountRepository(
private val account: AccountRecord,
private val events: MutableList<String>,
) : AccountRepository {
var deleted = false
private var pending: AppleRevocationOutboxRecord? = null
val pendingCount: Int get() = if (pending == null) 0 else 1
override suspend fun findById(accountId: UUID): AccountRecord? = account
override suspend fun deleteById(
accountId: UUID,
deletedAt: Instant,
tombstoneExpiresAt: Instant,
createRevocation: (String?) -> NewAppleRevocation?,
): Boolean {
deleted = true
events += "local-delete"
val revocation = createRevocation(account.encryptedAppleRefreshToken)
pending = revocation?.let {
AppleRevocationOutboxRecord(it.id, it.encryptedRefreshToken, 0)
}
return true
}
override suspend fun pendingAppleRevocations(
now: Instant,
limit: Int,
): List<AppleRevocationOutboxRecord> = listOfNotNull(pending)
override suspend fun rescheduleAppleRevocation(id: UUID, nextAttemptAt: Instant) = Unit
override suspend fun completeAppleRevocation(id: UUID, completedAt: Instant) {
pending = null
events += "outbox-complete"
}
}
private class RecordingAppleTokenClient(
private val events: MutableList<String>,
private val unavailable: Boolean = false,
) : AppleTokenClient {
var revokedToken: String? = null
override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange =
error("Not used by account deletion")
override suspend fun revokeRefreshToken(refreshToken: String) {
if (unavailable) throw AppleClientUnavailableException()
revokedToken = refreshToken
events += "apple-revoke"
}
}
@@ -0,0 +1,14 @@
package com.osglab.account.features.appleevents
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
class AppleEventRepositoryTest : FunSpec({
test("both official and observed Apple account deletion event names terminate accounts") {
isAccountTerminatingAppleEvent("account-delete").shouldBeTrue()
isAccountTerminatingAppleEvent("account-deleted").shouldBeTrue()
isAccountTerminatingAppleEvent("consent-revoked").shouldBeTrue()
isAccountTerminatingAppleEvent("email-enabled").shouldBeFalse()
}
})
@@ -0,0 +1,114 @@
package com.osglab.account.features.appleevents
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.RSASSASigner
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import com.osglab.account.features.auth.AppleJwksProvider
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.security.KeyPairGenerator
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.Date
class AppleEventVerifierTest : FunSpec({
val now = Instant.parse("2026-08-16T00:00:00Z")
val trustedKey = newRsaKey("apple-events")
val verifier = AppleEventVerifier(
config = AppleConfig(
teamId = null,
keyId = null,
clientId = "com.example.ios",
privateKeyPem = null,
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
),
jwksProvider = object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? =
trustedKey.toPublicJWK().takeIf { keyId == trustedKey.keyID }
},
clock = Clock.fixed(now, ZoneOffset.UTC),
)
test("accepts event fields only after the JWS signature is verified") {
val event = verifier.verify(signedEvent(trustedKey, now))
event shouldBe VerifiedAppleEvent(
eventId = "event-1",
type = "consent-revoked",
appleSubject = "apple-subject",
)
}
test("rejects an attacker-signed payload even when its claims look valid") {
val attackerKey = newRsaKey("apple-events")
shouldThrow<InvalidAppleEventException> {
verifier.verify(signedEvent(attackerKey, now))
}
}
test("rejects an unsigned JSON payload") {
shouldThrow<InvalidAppleEventException> {
verifier.verify("""{"events":{"type":"account-delete","sub":"apple-subject"}}""")
}
}
test("rejects an event without expiration or with ambiguous audiences") {
shouldThrow<InvalidAppleEventException> {
verifier.verify(signedEvent(trustedKey, now, includeExpiration = false))
}
shouldThrow<InvalidAppleEventException> {
verifier.verify(signedEvent(trustedKey, now, additionalAudience = "other-client"))
}
}
test("event string rendering never exposes the Apple subject") {
VerifiedAppleEvent("event-1", "consent-revoked", "sensitive-apple-subject").toString() shouldBe
"VerifiedAppleEvent(eventId=event-1, type=consent-revoked, appleSubject=[REDACTED])"
}
})
private fun newRsaKey(keyId: String): RSAKey {
val pair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
return RSAKey.Builder(pair.public as RSAPublicKey)
.privateKey(pair.private as RSAPrivateKey)
.keyID(keyId)
.build()
}
private fun signedEvent(
key: RSAKey,
now: Instant,
includeExpiration: Boolean = true,
additionalAudience: String? = null,
): String {
val claimsBuilder = JWTClaimsSet.Builder()
.issuer("https://appleid.apple.com")
.audience(listOfNotNull("com.example.ios", additionalAudience))
.jwtID("event-1")
.issueTime(Date.from(now))
.claim(
"events",
"""{"type":"consent-revoked","sub":"apple-subject"}""",
)
if (includeExpiration) {
claimsBuilder.expirationTime(Date.from(now.plusSeconds(300)))
}
val claims = claimsBuilder.build()
return SignedJWT(
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.keyID).build(),
claims,
).apply {
sign(RSASSASigner(key.toPrivateKey()))
}.serialize()
}
@@ -0,0 +1,74 @@
package com.osglab.account.features.auth
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.crypto.ECDSAVerifier
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.Base64
class AppleClientSecretProviderTest : FunSpec({
test("creates a verifiable short-lived ES256 Apple client secret") {
val now = Instant.parse("2026-08-16T00:00:00Z")
val keyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair()
val privateKeyPem = Base64.getMimeEncoder(64, "\n".toByteArray())
.encodeToString(keyPair.private.encoded)
.let { "-----BEGIN PRIVATE KEY-----\n$it\n-----END PRIVATE KEY-----" }
val config = AppleConfig(
teamId = "TEAM123",
keyId = "KEY123",
clientId = "com.example.ios",
privateKeyPem = privateKeyPem,
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
)
val serialized = AppleClientSecretProvider(
config,
Clock.fixed(now, ZoneOffset.UTC),
).create()
val jwt = SignedJWT.parse(serialized)
jwt.header.algorithm shouldBe JWSAlgorithm.ES256
jwt.header.keyID shouldBe "KEY123"
jwt.verify(ECDSAVerifier(keyPair.public as ECPublicKey)) shouldBe true
jwt.jwtClaimsSet.issuer shouldBe "TEAM123"
jwt.jwtClaimsSet.subject shouldBe "com.example.ios"
jwt.jwtClaimsSet.audience shouldBe listOf("https://appleid.apple.com")
jwt.jwtClaimsSet.issueTime.toInstant() shouldBe now
jwt.jwtClaimsSet.expirationTime.toInstant() shouldBe now.plusSeconds(300)
}
test("rejects an EC key that is not Apple P-256") {
val keyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp384r1"))
}.generateKeyPair()
val privateKeyPem = Base64.getMimeEncoder(64, "\n".toByteArray())
.encodeToString(keyPair.private.encoded)
.let { "-----BEGIN PRIVATE KEY-----\n$it\n-----END PRIVATE KEY-----" }
val config = AppleConfig(
teamId = "TEAM123",
keyId = "KEY123",
clientId = "com.example.ios",
privateKeyPem = privateKeyPem,
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
)
shouldThrow<AppleClientUnavailableException> {
AppleClientSecretProvider(config).create()
}
}
})
@@ -0,0 +1,180 @@
package com.osglab.account.features.auth
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.RSASSASigner
import com.nimbusds.jose.jwk.KeyUse
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.security.MessageDigest
import java.security.KeyPairGenerator
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.Date
class AppleIdentityTokenVerifierTest : FunSpec({
val now = Instant.parse("2026-08-15T12:00:00Z")
val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
val jwk = RSAKey.Builder(keyPair.public as RSAPublicKey)
.privateKey(keyPair.private as RSAPrivateKey)
.keyID("apple-key")
.build()
val config = AppleConfig(
teamId = null,
keyId = null,
clientId = "com.example.ios",
privateKeyPem = null,
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
)
val provider = object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? =
jwk.toPublicJWK().takeIf { keyId == "apple-key" }
}
val verifier = AppleIdentityTokenVerifier(
config,
provider,
Clock.fixed(now, ZoneOffset.UTC),
)
test("accepts a correctly signed token with matching nonce") {
val token = identityToken(
jwk,
now,
"com.example.ios",
sha256("nonce-123"),
)
verifier.verify(token, "nonce-123").subject shouldBe "apple-subject"
}
test("rejects a token for another audience") {
val token = identityToken(jwk, now, "other-client", sha256("nonce-123"))
shouldThrow<AppleTokenInvalidException> {
verifier.verify(token, "nonce-123")
}
}
test("rejects ambiguous audiences and a missing expiration") {
val ambiguousAudience = identityToken(
jwk,
now,
"com.example.ios",
sha256("nonce-123"),
additionalAudience = "other-client",
)
val missingExpiration = identityToken(
jwk,
now,
"com.example.ios",
sha256("nonce-123"),
includeExpiration = false,
)
shouldThrow<AppleTokenInvalidException> {
verifier.verify(ambiguousAudience, "nonce-123")
}
shouldThrow<AppleTokenInvalidException> {
verifier.verify(missingExpiration, "nonce-123")
}
}
test("rejects a JWK not designated for signature verification") {
val unsuitableKey = RSAKey.Builder(keyPair.public as RSAPublicKey)
.keyID(jwk.keyID)
.keyUse(KeyUse.ENCRYPTION)
.build()
val unsuitableVerifier = AppleIdentityTokenVerifier(
config,
object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? = unsuitableKey
},
Clock.fixed(now, ZoneOffset.UTC),
)
shouldThrow<AppleTokenInvalidException> {
unsuitableVerifier.verify(
identityToken(jwk, now, "com.example.ios", sha256("nonce-123")),
"nonce-123",
)
}
}
test("rejects an expired token or an untrusted issuer") {
val expired = identityToken(
jwk = jwk,
now = now.minusSeconds(600),
audience = "com.example.ios",
nonce = sha256("nonce-123"),
expiresAt = now.minusSeconds(60),
)
val wrongIssuer = identityToken(
jwk = jwk,
now = now,
audience = "com.example.ios",
nonce = sha256("nonce-123"),
issuer = "https://attacker.example",
)
shouldThrow<AppleTokenInvalidException> {
verifier.verify(expired, "nonce-123")
}
shouldThrow<AppleTokenInvalidException> {
verifier.verify(wrongIssuer, "nonce-123")
}
}
test("nonce verification accepts only the SHA-256 claim") {
AppleNonceVerifier.matches("nonce-123", sha256("nonce-123")) shouldBe true
AppleNonceVerifier.matches("nonce-123", "nonce-123") shouldBe false
AppleNonceVerifier.matches("nonce-123", sha256("another-nonce")) shouldBe false
}
test("Apple identity string rendering never exposes the subject") {
AppleIdentity("sensitive-apple-subject").toString() shouldBe
"AppleIdentity(subject=[REDACTED])"
}
})
private fun identityToken(
jwk: RSAKey,
now: Instant,
audience: String,
nonce: String,
issuer: String = "https://appleid.apple.com",
expiresAt: Instant = now.plusSeconds(300),
additionalAudience: String? = null,
includeExpiration: Boolean = true,
): String {
val claimsBuilder = JWTClaimsSet.Builder()
.issuer(issuer)
.audience(listOfNotNull(audience, additionalAudience))
.subject("apple-subject")
.issueTime(Date.from(now))
.claim("nonce", nonce)
if (includeExpiration) {
claimsBuilder.expirationTime(Date.from(expiresAt))
}
val claims = claimsBuilder.build()
return SignedJWT(
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(jwk.keyID).build(),
claims,
).apply {
sign(RSASSASigner(jwk.toPrivateKey()))
}.serialize()
}
private fun sha256(value: String): String =
MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
@@ -0,0 +1,123 @@
package com.osglab.account.features.auth
import com.osglab.account.config.AppleConfig
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.forms.FormDataContent
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import java.net.SocketTimeoutException
class AppleTokenClientTest : FunSpec({
test("authorization code exchange uses the replaceable HTTP boundary") {
val engine = MockEngine { request ->
request.url.toString() shouldBe "https://appleid.apple.com/auth/token"
val form = (request.body as FormDataContent).formData
form["client_id"] shouldBe "com.example.ios"
form["client_secret"] shouldBe "signed-client-secret"
form["code"] shouldBe "one-time-code"
form["grant_type"] shouldBe "authorization_code"
respond(
content = """{"refresh_token":"refresh","id_token":"identity"}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) { install(HttpTimeout) }
val apple = HttpAppleTokenClient(
client,
appleTokenClientConfig(),
AppleClientSecretSigner { "signed-client-secret" },
)
apple.exchangeAuthorizationCode("one-time-code") shouldBe
AppleTokenExchange("refresh", "identity")
client.close()
}
test("provider timeout is mapped to a retryable failure") {
val engine = MockEngine {
throw SocketTimeoutException("simulated provider timeout")
}
val client = HttpClient(engine) { install(HttpTimeout) }
val apple = HttpAppleTokenClient(
httpClient = client,
config = appleTokenClientConfig(),
clientSecretProvider = AppleClientSecretSigner { "signed-client-secret" },
requestTimeoutMillis = 10,
)
shouldThrow<AppleTokenEndpointException> {
apple.exchangeAuthorizationCode("one-time-code")
}.retryable shouldBe true
client.close()
}
test("authorization code exchange rejects a response without a refresh token") {
val engine = MockEngine {
respond(
content = """{"id_token":"identity"}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) { install(HttpTimeout) }
val apple = HttpAppleTokenClient(
client,
appleTokenClientConfig(),
AppleClientSecretSigner { "signed-client-secret" },
)
shouldThrow<AppleTokenEndpointException> {
apple.exchangeAuthorizationCode("one-time-code")
}.retryable shouldBe false
client.close()
}
test("rate limiting is retryable without reflecting the provider response") {
val engine = MockEngine {
respond(
content = "upstream detail that must not be reflected",
status = HttpStatusCode.TooManyRequests,
)
}
val client = HttpClient(engine) { install(HttpTimeout) }
val apple = HttpAppleTokenClient(
client,
appleTokenClientConfig(),
AppleClientSecretSigner { "signed-client-secret" },
)
val failure = shouldThrow<AppleTokenEndpointException> {
apple.exchangeAuthorizationCode("one-time-code")
}
failure.retryable shouldBe true
failure.message shouldBe "Apple rejected the authorization code"
client.close()
}
test("token exchange values are redacted from string rendering") {
AppleTokenExchange("refresh-secret", "identity-secret").toString() shouldBe
"AppleTokenExchange(refreshToken=[REDACTED], identityToken=[REDACTED])"
}
})
private fun appleTokenClientConfig() = AppleConfig(
teamId = "TEAM",
keyId = "KEY",
clientId = "com.example.ios",
privateKeyPem = "unused-by-test-signer",
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
)
@@ -0,0 +1,78 @@
package com.osglab.account.features.auth
import com.osglab.account.common.security.SessionJwt
import com.osglab.account.config.SessionConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
class SessionAccessAuthenticatorTest : FunSpec({
test("a signed access token is rejected immediately after its family or account is deleted") {
val now = Instant.parse("2026-08-16T00:00:00Z")
val clock = Clock.fixed(now, ZoneOffset.UTC)
val repository = MutableSessionStateRepository()
val jwt = SessionJwt(
SessionConfig(
issuer = "https://issuer.example",
audience = "ios",
hmacSecret = ByteArray(32) { 1 },
accessMinutes = 15,
refreshDays = 30,
),
clock,
)
val accountId = UUID.randomUUID()
val familyId = UUID.randomUUID()
val token = jwt.issue(accountId, familyId).value
val authenticator = SessionAccessAuthenticator(jwt, repository, clock)
authenticator.authenticate(token).shouldNotBeNull()
repository.active = false
authenticator.authenticate(token).shouldBeNull()
}
})
private class MutableSessionStateRepository : AuthRepository {
var active = true
override suspend fun isSessionActive(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = active
override suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount = error("Not used")
override suspend fun updateAppleRefreshToken(
accountId: UUID,
encryptedToken: String,
now: Instant,
) = error("Not used")
override suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession = error("Not used")
override suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult = error("Not used")
override suspend fun revokeSessionFamily(accountId: UUID, sessionId: UUID, now: Instant): Boolean =
error("Not used")
override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) = Unit
}
@@ -0,0 +1,405 @@
package com.osglab.account.features.auth
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.RSASSASigner
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.common.errors.TokenReuseException
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.common.security.RefreshTokenGenerator
import com.osglab.account.common.security.SessionJwt
import com.osglab.account.common.security.TokenHash
import com.osglab.account.config.AppleConfig
import com.osglab.account.config.IntegrityConfig
import com.osglab.account.config.IntegrityPolicy
import com.osglab.account.config.SessionConfig
import com.osglab.account.features.integrity.IntegrityService
import com.osglab.account.features.integrity.IntegrityEvidence
import com.osglab.account.features.integrity.UnavailableAppAttestVerifier
import com.osglab.account.features.integrity.UnavailableDeviceCheckVerifier
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.Base64
import java.util.Date
import java.util.UUID
class SessionServiceTest : FunSpec({
test("refresh tokens contain 256 bits of URL-safe randomness") {
val generator = RefreshTokenGenerator()
val first = generator.newRefreshToken()
val second = generator.newRefreshToken()
Base64.getUrlDecoder().decode(first).size shouldBe 32
(first != second) shouldBe true
}
test("Apple sign-in verifies both tokens and persists only protected credentials") {
val now = Instant.parse("2026-08-16T00:00:00Z")
val clock = Clock.fixed(now, ZoneOffset.UTC)
val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
val key = RSAKey.Builder(keyPair.public as RSAPublicKey)
.privateKey(keyPair.private as RSAPrivateKey)
.keyID("apple-key")
.build()
val nonce = "one-time-nonce"
val identityToken = signedIdentityToken(key, now, nonce)
val accountId = UUID.randomUUID()
val repository = SuccessfulAuthRepository(accountId)
val sessionConfig = sessionConfig()
val encryptor = FieldEncryptor(ByteArray(32) { 4 })
var exchangedCode: String? = null
val service = SessionService(
repository = repository,
appleIdentityVerifier = AppleIdentityTokenVerifier(
appleConfig(),
object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? =
key.toPublicJWK().takeIf { keyId == key.keyID }
},
clock,
),
appleTokenClient = object : AppleTokenClient {
override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange {
exchangedCode = code
return AppleTokenExchange("apple-refresh", identityToken)
}
override suspend fun revokeRefreshToken(refreshToken: String) = error("Not used")
},
integrityService = monitorOnlyIntegrityService(),
sessionJwt = SessionJwt(sessionConfig, clock),
fieldEncryptor = encryptor,
identityFingerprint = IdentityFingerprint(ByteArray(32) { 6 }),
sessionConfig = sessionConfig,
clock = clock,
)
val tokens = service.signInWithApple(
identityToken = identityToken,
authorizationCode = "authorization-code",
nonce = nonce,
integrityEvidence = IntegrityEvidence(),
)
exchangedCode shouldBe "authorization-code"
tokens.accountId shouldBe accountId
repository.refreshTokenHash shouldBe TokenHash.sha256(tokens.refreshToken)
(repository.encryptedAppleSubject == "apple-subject") shouldBe false
(repository.encryptedAppleRefreshToken == "apple-refresh") shouldBe false
encryptor.decrypt(
requireNotNull(repository.encryptedAppleSubject),
appleSubjectContext(requireNotNull(repository.identityFingerprint)),
) shouldBe "apple-subject"
encryptor.decrypt(
requireNotNull(repository.encryptedAppleRefreshToken),
appleRefreshContext(accountId),
) shouldBe "apple-refresh"
SessionJwt(sessionConfig, clock).verify(tokens.accessToken)?.sessionId shouldBe
repository.sessionId
}
test("refresh token reuse is surfaced and no replacement tokens are issued") {
val sessionConfig = sessionConfig()
val service = SessionService(
repository = ReuseDetectingRepository,
appleIdentityVerifier = AppleIdentityTokenVerifier(
appleConfig(),
object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? = null
},
),
appleTokenClient = UnavailableAppleTokenClient(),
integrityService = monitorOnlyIntegrityService(),
sessionJwt = SessionJwt(sessionConfig),
fieldEncryptor = FieldEncryptor(ByteArray(32) { 4 }),
identityFingerprint = IdentityFingerprint(ByteArray(32) { 6 }),
sessionConfig = sessionConfig,
)
shouldThrow<TokenReuseException> {
service.refresh("already-used-token")
}
}
test("concurrent refresh accepts once and revokes the family on replay") {
val repository = ConcurrentRotationRepository()
val sessionConfig = sessionConfig()
val service = SessionService(
repository = repository,
appleIdentityVerifier = AppleIdentityTokenVerifier(
appleConfig(),
object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? = null
},
),
appleTokenClient = UnavailableAppleTokenClient(),
integrityService = monitorOnlyIntegrityService(),
sessionJwt = SessionJwt(sessionConfig),
fieldEncryptor = FieldEncryptor(ByteArray(32) { 4 }),
identityFingerprint = IdentityFingerprint(ByteArray(32) { 6 }),
sessionConfig = sessionConfig,
)
val results = coroutineScope {
List(2) {
async { runCatching { service.refresh("same-refresh-token") } }
}.awaitAll()
}
results.count { it.isSuccess } shouldBe 1
results.count { it.exceptionOrNull() is TokenReuseException } shouldBe 1
repository.familyRevoked shouldBe true
}
test("refresh rotation policy rotates only an active unconsumed token") {
val now = Instant.parse("2026-08-16T00:00:00Z")
RefreshRotationPolicy.decide(
revoked = false,
replaced = false,
expiresAt = now.plusSeconds(1),
now = now,
) shouldBe RefreshRotationDecision.ROTATE
RefreshRotationPolicy.decide(
revoked = false,
replaced = false,
expiresAt = now,
now = now,
) shouldBe RefreshRotationDecision.REVOKE_EXPIRED
}
test("refresh rotation policy treats any consumed token as family reuse") {
val now = Instant.parse("2026-08-16T00:00:00Z")
RefreshRotationPolicy.decide(
revoked = true,
replaced = false,
expiresAt = now.plusSeconds(60),
now = now,
) shouldBe RefreshRotationDecision.REVOKE_REUSED_FAMILY
RefreshRotationPolicy.decide(
revoked = false,
replaced = true,
expiresAt = now.plusSeconds(60),
now = now,
) shouldBe RefreshRotationDecision.REVOKE_REUSED_FAMILY
}
})
private class SuccessfulAuthRepository(
private val accountId: UUID,
) : AuthRepository {
val sessionId: UUID = UUID.randomUUID()
var encryptedAppleSubject: String? = null
var identityFingerprint: String? = null
var encryptedAppleRefreshToken: String? = null
var refreshTokenHash: String? = null
override suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount {
this.identityFingerprint = identityFingerprint
this.encryptedAppleSubject = encryptedAppleSubject
return AuthAccount(accountId, identityFingerprint, false)
}
override suspend fun updateAppleRefreshToken(
accountId: UUID,
encryptedToken: String,
now: Instant,
) {
encryptedAppleRefreshToken = encryptedToken
}
override suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession {
this.refreshTokenHash = refreshTokenHash
return CreatedSession(accountId, sessionId, sessionId)
}
override suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult = error("Not used")
override suspend fun revokeSessionFamily(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = error("Not used")
override suspend fun isSessionActive(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = true
override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) = Unit
}
private data object ReuseDetectingRepository : AuthRepository {
override suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount =
error("Not used")
override suspend fun updateAppleRefreshToken(
accountId: UUID,
encryptedToken: String,
now: Instant,
): Unit = error("Not used")
override suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession = error("Not used")
override suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult = RefreshRotationResult.ReuseDetected
override suspend fun revokeSessionFamily(accountId: UUID, sessionId: UUID, now: Instant): Boolean =
error("Not used")
override suspend fun isSessionActive(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = false
override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) = Unit
}
private class ConcurrentRotationRepository : AuthRepository {
private val mutex = Mutex()
private var consumed = false
var familyRevoked = false
private set
override suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult = mutex.withLock {
if (consumed) {
familyRevoked = true
RefreshRotationResult.ReuseDetected
} else {
consumed = true
RefreshRotationResult.Rotated(
accountId = UUID.randomUUID(),
sessionId = UUID.randomUUID(),
familyId = UUID.randomUUID(),
)
}
}
override suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount = error("Not used")
override suspend fun updateAppleRefreshToken(
accountId: UUID,
encryptedToken: String,
now: Instant,
) = error("Not used")
override suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession = error("Not used")
override suspend fun revokeSessionFamily(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = error("Not used")
override suspend fun isSessionActive(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = !familyRevoked
override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) = Unit
}
private fun appleConfig() = AppleConfig(
teamId = null,
keyId = null,
clientId = "com.example.ios",
privateKeyPem = null,
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
)
private fun sessionConfig() = SessionConfig(
issuer = "https://issuer.example",
audience = "ios",
hmacSecret = ByteArray(32) { 8 },
accessMinutes = 15,
refreshDays = 30,
)
private fun monitorOnlyIntegrityService() = IntegrityService(
IntegrityConfig(IntegrityPolicy.MONITOR, IntegrityPolicy.MONITOR),
UnavailableDeviceCheckVerifier(),
UnavailableAppAttestVerifier(),
)
private fun signedIdentityToken(key: RSAKey, now: Instant, rawNonce: String): String {
val nonce = MessageDigest.getInstance("SHA-256")
.digest(rawNonce.toByteArray())
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
val claims = JWTClaimsSet.Builder()
.issuer("https://appleid.apple.com")
.audience("com.example.ios")
.subject("apple-subject")
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plusSeconds(300)))
.claim("nonce", nonce)
.build()
return SignedJWT(
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.keyID).build(),
claims,
).apply {
sign(RSASSASigner(key.toPrivateKey()))
}.serialize()
}
@@ -0,0 +1,576 @@
package com.osglab.account.features.credits
import com.osglab.account.features.credits.domain.CreditCostCalculator
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.InsufficientCredits
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.domain.ReservationStatus
import com.osglab.account.features.credits.domain.ReservationStateRules
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.credits.domain.UsageMeasurement
import com.osglab.account.features.credits.domain.externalIdempotencyKey
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import io.kotest.core.spec.style.FunSpec
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.types.shouldBeInstanceOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlin.random.Random
class CreditServiceTest : FunSpec({
val now = Instant.parse("2026-08-15T00:00:00Z")
test("ASR and LLM rates round each billed dimension upward") {
CreditCostCalculator.calculate(
asrRate(now),
UsageMeasurement.Asr(durationMillis = 101),
) shouldBeExactly 2
CreditCostCalculator.calculate(
llmRate(now),
UsageMeasurement.Llm(inputTokens = 1001, outputTokens = 1),
) shouldBeExactly 4
}
test("cost calculation avoids intermediate overflow and rejects an unrepresentable result") {
CreditCostCalculator.calculate(
asrRate(now).copy(
asrCreditsNumerator = 2,
asrMillisDenominator = 2,
),
UsageMeasurement.Asr(Long.MAX_VALUE),
) shouldBeExactly Long.MAX_VALUE
shouldThrow<InvalidCreditRequest> {
CreditCostCalculator.calculate(
asrRate(now).copy(
asrCreditsNumerator = 2,
asrMillisDenominator = 1,
),
UsageMeasurement.Asr(Long.MAX_VALUE),
)
}
}
test("signup grant is idempotent") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, 100, "signup-key-001")
service.grantSignupTrial(userId, 100, "signup-key-001")
store.balance(userId) shouldBeExactly 100
store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1
}
test("balance overflow rolls back without appending a ledger entry") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, Long.MAX_VALUE, "signup-max-key")
shouldThrow<InvalidCreditRequest> {
service.grantSignupTrial(userId, 1, "signup-overflow-key")
}
store.balance(userId) shouldBeExactly Long.MAX_VALUE
store.ledger.filter { it.userId == userId } shouldHaveSize 1
}
test("concurrent reservations cannot make the account negative") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, 100, "signup-key-002")
val results = coroutineScope {
listOf("reserve-key-001", "reserve-key-002").map { key ->
async(Dispatchers.Default) {
runCatching {
service.reserve(
userId = userId,
provider = "asr-provider",
model = "asr-model",
estimatedUsage = UsageMeasurement.Asr(6_000),
managedCall = true,
idempotencyKey = key,
)
}
}
}.awaitAll()
}
results.count(Result<*>::isSuccess) shouldBe 1
results.single(Result<*>::isFailure).exceptionOrNull()
.shouldBeInstanceOf<InsufficientCredits>()
store.balance(userId) shouldBeExactly 40
}
test("first positive managed settlement rewards both users exactly once") {
val store = storeWithRates(now)
val service = service(store, now)
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
val binding = ReferralBinding(
id = UUID.randomUUID(),
inviterUserId = inviter,
inviteeUserId = invitee,
codeId = UUID.randomUUID(),
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
)
store.inTransaction { it.referrals.insertBindingIfAbsent(binding) }
service.grantSignupTrial(invitee, 100, "signup-key-003")
val reservation = service.reserve(
userId = invitee,
provider = "asr-provider",
model = "asr-model",
estimatedUsage = UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "reserve-key-003",
)
val first = service.settle(
invitee,
reservation.id,
UsageMeasurement.Asr(500),
"settle-key-003",
)
val retried = service.settle(
invitee,
reservation.id,
UsageMeasurement.Asr(500),
"settle-key-003",
)
first.status shouldBe ReservationStatus.SETTLED
retried shouldBe first
store.balance(invitee) shouldBeExactly 125
store.balance(inviter) shouldBeExactly 30
store.ledger.filter {
it.type == LedgerEntryType.REFERRAL_INVITEE ||
it.type == LedgerEntryType.REFERRAL_INVITER
} shouldHaveSize 2
store.usageRecords shouldHaveSize 1
store.usageRecords.single().rateVersionId shouldBe reservation.rateVersionId
store.bindings.getValue(invitee).rewardSettlementId shouldBe reservation.id
}
test("zero-cost managed settlement does not consume first valid referral reward") {
val store = storeWithRates(now)
val service = service(store, now)
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
store.inTransaction {
it.referrals.insertBindingIfAbsent(
ReferralBinding(
id = UUID.randomUUID(),
inviterUserId = inviter,
inviteeUserId = invitee,
codeId = UUID.randomUUID(),
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
),
)
}
service.grantSignupTrial(invitee, 100, "zero-signup-key")
val zeroCost = service.reserve(
invitee,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "zero-reserve-key",
)
service.settle(
invitee,
zeroCost.id,
UsageMeasurement.Asr(0),
"zero-settle-key",
)
store.bindings.getValue(invitee).rewardStatus shouldBe ReferralRewardStatus.PENDING
val qualifying = service.reserve(
invitee,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "valid-reserve-key",
)
service.settle(
invitee,
qualifying.id,
UsageMeasurement.Asr(1),
"valid-settle-key",
)
store.bindings.getValue(invitee).rewardSettlementId shouldBe qualifying.id
store.ledger.count {
it.type == LedgerEntryType.REFERRAL_INVITER ||
it.type == LedgerEntryType.REFERRAL_INVITEE
} shouldBe 2
}
test("concurrent settlement replay grants both referral sides only once") {
val store = storeWithRates(now)
val service = service(store, now)
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
store.inTransaction {
it.referrals.insertBindingIfAbsent(
ReferralBinding(
id = UUID.randomUUID(),
inviterUserId = inviter,
inviteeUserId = invitee,
codeId = UUID.randomUUID(),
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
),
)
}
service.grantSignupTrial(invitee, 100, "concurrent-signup-key")
val reservation = service.reserve(
userId = invitee,
provider = "asr-provider",
model = "asr-model",
estimatedUsage = UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "concurrent-reserve-key",
)
val results = coroutineScope {
List(8) {
async(Dispatchers.Default) {
service.settle(
userId = invitee,
reservationId = reservation.id,
actualUsage = UsageMeasurement.Asr(500),
idempotencyKey = "concurrent-settle-key",
)
}
}.awaitAll()
}
results.distinct() shouldHaveSize 1
store.usageRecords shouldHaveSize 1
store.ledger.count {
it.type == LedgerEntryType.REFERRAL_INVITEE ||
it.type == LedgerEntryType.REFERRAL_INVITER
} shouldBe 2
store.balance(inviter) shouldBeExactly 30
store.balance(invitee) shouldBeExactly 125
}
test("ledger projection remains non-negative across randomized terminal operations") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, 20_000, "property-signup-key")
val random = Random(42)
repeat(100) { index ->
val estimate = random.nextLong(1, 5_000)
val reservation = service.reserve(
userId,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(estimate),
managedCall = false,
idempotencyKey = "property-reserve-$index",
)
if (index % 3 == 0) {
service.release(userId, reservation.id, "property-release-$index")
} else {
val actual = random.nextLong(0, estimate + 1)
service.settle(
userId,
reservation.id,
UsageMeasurement.Asr(actual),
"property-settle-$index",
)
if (index % 5 == 0) {
service.refund(userId, reservation.id, "property-refund-$index")
}
}
}
var projection = 0L
store.ledger.filter { it.userId == userId }.forEach { entry ->
projection = Math.addExact(projection, entry.amountDelta)
entry.balanceAfter shouldBeExactly projection
(projection >= 0) shouldBe true
}
store.balance(userId) shouldBeExactly projection
}
test("campaign cap is consumed once and later qualification is not rewarded") {
val store = storeWithRates(now)
val service = service(store, now)
val campaignId = UUID.randomUUID()
store.campaigns[campaignId] = ReferralCampaign(
id = campaignId,
name = "One reward",
startsAt = now.minusSeconds(60),
endsAt = null,
bindingWindowSeconds = 604_800,
inviterRewardCredits = 7,
inviteeRewardCredits = 5,
maxRewardedBindings = 1,
budgetCredits = 12,
enabled = true,
)
store.campaignBudgets[campaignId] = ReferralCampaignBudget(campaignId, 0, 0, now)
val inviter = UUID.randomUUID()
val invitees = List(2) { UUID.randomUUID() }
invitees.forEach { invitee ->
store.inTransaction {
it.referrals.insertBindingIfAbsent(
ReferralBinding(
id = UUID.randomUUID(),
inviterUserId = inviter,
inviteeUserId = invitee,
codeId = UUID.randomUUID(),
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
campaignId = campaignId,
),
)
}
service.grantSignupTrial(invitee, 100, "campaign-signup-$invitee")
val reservation = service.reserve(
invitee,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "campaign-reserve-$invitee",
)
service.settle(
invitee,
reservation.id,
UsageMeasurement.Asr(500),
"campaign-settle-$invitee",
)
}
store.ledger.count {
it.type == LedgerEntryType.REFERRAL_INVITER ||
it.type == LedgerEntryType.REFERRAL_INVITEE
} shouldBe 2
store.campaignBudgets.getValue(campaignId).rewardedBindings shouldBeExactly 1
store.bindings.getValue(invitees[1]).rewardStatus shouldBe
ReferralRewardStatus.INELIGIBLE_BUDGET
}
test("exhausted campaign counters fail closed without partial rewards") {
val store = storeWithRates(now)
val service = service(store, now)
val campaignId = UUID.randomUUID()
store.campaigns[campaignId] = ReferralCampaign(
id = campaignId,
name = "Exhausted",
startsAt = now.minusSeconds(60),
endsAt = null,
bindingWindowSeconds = 604_800,
inviterRewardCredits = 7,
inviteeRewardCredits = 5,
maxRewardedBindings = null,
budgetCredits = null,
enabled = true,
)
store.campaignBudgets[campaignId] = ReferralCampaignBudget(
campaignId,
Long.MAX_VALUE,
Long.MAX_VALUE,
now,
)
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
store.inTransaction {
it.referrals.insertBindingIfAbsent(
ReferralBinding(
id = UUID.randomUUID(),
inviterUserId = inviter,
inviteeUserId = invitee,
codeId = UUID.randomUUID(),
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
campaignId = campaignId,
),
)
}
service.grantSignupTrial(invitee, 100, "exhausted-signup-key")
val reservation = service.reserve(
invitee,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "exhausted-reserve-key",
)
service.settle(
invitee,
reservation.id,
UsageMeasurement.Asr(500),
"exhausted-settle-key",
)
store.bindings.getValue(invitee).rewardStatus shouldBe
ReferralRewardStatus.INELIGIBLE_BUDGET
store.ledger.none {
it.type == LedgerEntryType.REFERRAL_INVITER ||
it.type == LedgerEntryType.REFERRAL_INVITEE
} shouldBe true
}
test("release and refund restore only the corresponding debit") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, 100, "signup-key-004")
val released = service.reserve(
userId,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = false,
idempotencyKey = "reserve-key-004",
)
service.release(userId, released.id, "release-key-004")
service.release(userId, released.id, "release-key-004")
val settled = service.reserve(
userId,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = false,
idempotencyKey = "reserve-key-005",
)
service.settle(userId, settled.id, UsageMeasurement.Asr(500), "settle-key-005")
service.refund(userId, settled.id, "refund-key-005")
service.refund(userId, settled.id, "refund-key-005")
store.balance(userId) shouldBeExactly 100
store.ledger.filter { it.type == LedgerEntryType.USAGE_RELEASE } shouldHaveSize 1
store.ledger.filter { it.type == LedgerEntryType.USAGE_REFUND } shouldHaveSize 1
}
test("settlement replay rejects different actual usage") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, 100, "signup-key-006")
val reservation = service.reserve(
userId,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = false,
idempotencyKey = "reserve-key-006",
)
service.settle(userId, reservation.id, UsageMeasurement.Asr(500), "settle-key-006")
shouldThrow<CreditConflict> {
service.settle(userId, reservation.id, UsageMeasurement.Asr(600), "settle-key-006")
}
}
test("public idempotency values cannot occupy the internal reward namespace") {
externalIdempotencyKey("internal:referral:known-binding:invitee") shouldNotBe
"internal:referral:known-binding:invitee"
}
test("reservation state rules allow only settle release and refund transitions") {
ReservationStateRules.canTransition(
ReservationStatus.RESERVED,
ReservationStatus.SETTLED,
) shouldBe true
ReservationStateRules.canTransition(
ReservationStatus.RESERVED,
ReservationStatus.RELEASED,
) shouldBe true
ReservationStateRules.canTransition(
ReservationStatus.SETTLED,
ReservationStatus.REFUNDED,
) shouldBe true
ReservationStatus.entries.forEach { target ->
ReservationStateRules.canTransition(
ReservationStatus.REFUNDED,
target,
) shouldBe false
}
ReservationStateRules.canTransition(
ReservationStatus.RELEASED,
ReservationStatus.SETTLED,
) shouldBe false
}
})
private fun service(store: TestBillingStore, now: Instant) = CreditService(
transactions = store,
referralRewards = ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30),
clock = Clock.fixed(now, ZoneOffset.UTC),
)
private fun storeWithRates(now: Instant) = TestBillingStore().also {
val asr = asrRate(now)
val llm = llmRate(now)
it.rates[asr.id] = asr
it.rates[llm.id] = llm
}
private fun asrRate(now: Instant) = CreditRateVersion(
id = UUID.nameUUIDFromBytes("asr-rate".toByteArray()),
kind = UsageKind.ASR,
provider = "asr-provider",
model = "asr-model",
effectiveFrom = now.minusSeconds(60),
effectiveUntil = null,
asrCreditsNumerator = 1,
asrMillisDenominator = 100,
inputCreditsNumerator = null,
inputTokensDenominator = null,
outputCreditsNumerator = null,
outputTokensDenominator = null,
)
private fun llmRate(now: Instant) = CreditRateVersion(
id = UUID.nameUUIDFromBytes("llm-rate".toByteArray()),
kind = UsageKind.LLM,
provider = "llm-provider",
model = "llm-model",
effectiveFrom = now.minusSeconds(60),
effectiveUntil = null,
asrCreditsNumerator = null,
asrMillisDenominator = null,
inputCreditsNumerator = 2,
inputTokensDenominator = 1_000,
outputCreditsNumerator = 3,
outputTokensDenominator = 1_000,
)
@@ -0,0 +1,244 @@
package com.osglab.account.features.credits
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
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.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.credits.repositories.BillingUnitOfWork
import com.osglab.account.features.credits.repositories.CreditsRepository
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID
import com.osglab.account.features.referrals.domain.ReferralCampaign
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 java.time.Instant
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
private val lock = ReentrantLock()
private val accounts = mutableMapOf<UUID, CreditAccount>()
val ledger = mutableListOf<LedgerEntry>()
val usageRecords = mutableListOf<CreditUsageRecord>()
val reservations = mutableMapOf<UUID, CreditReservation>()
val rates = mutableMapOf<UUID, CreditRateVersion>()
val codes = mutableMapOf<UUID, ReferralCode>()
val bindings = mutableMapOf<UUID, ReferralBinding>()
val campaigns = mutableMapOf(
DEFAULT_REFERRAL_CAMPAIGN_ID to ReferralCampaign(
id = DEFAULT_REFERRAL_CAMPAIGN_ID,
name = "Default",
startsAt = Instant.EPOCH,
endsAt = null,
bindingWindowSeconds = 7 * 24 * 60 * 60,
inviterRewardCredits = 30,
inviteeRewardCredits = 30,
maxRewardedBindings = null,
budgetCredits = null,
enabled = true,
),
)
val campaignBudgets = mutableMapOf(
DEFAULT_REFERRAL_CAMPAIGN_ID to ReferralCampaignBudget(
campaignId = DEFAULT_REFERRAL_CAMPAIGN_ID,
rewardedBindings = 0,
spentCredits = 0,
updatedAt = Instant.EPOCH,
),
)
override val credits: CreditsRepository = Credits()
override val referrals: ReferralsRepository = Referrals()
override suspend fun <T> inTransaction(block: (BillingUnitOfWork) -> T): T =
lock.withLock {
val accountSnapshot = accounts.toMap()
val ledgerSnapshot = ledger.toList()
val usageSnapshot = usageRecords.toList()
val reservationSnapshot = reservations.toMap()
val codeSnapshot = codes.toMap()
val bindingSnapshot = bindings.toMap()
val budgetSnapshot = campaignBudgets.toMap()
try {
block(this)
} catch (failure: Throwable) {
accounts.replaceWith(accountSnapshot)
ledger.replaceWith(ledgerSnapshot)
usageRecords.replaceWith(usageSnapshot)
reservations.replaceWith(reservationSnapshot)
codes.replaceWith(codeSnapshot)
bindings.replaceWith(bindingSnapshot)
campaignBudgets.replaceWith(budgetSnapshot)
throw failure
}
}
fun balance(userId: UUID): Long = lock.withLock { accounts[userId]?.balance ?: 0 }
private inner class Credits : CreditsRepository {
override fun createAccountIfAbsent(userId: UUID, now: Instant) {
accounts.putIfAbsent(userId, CreditAccount(userId, 0, now))
}
override fun lockAccount(userId: UUID): CreditAccount =
accounts[userId] ?: throw CreditNotFound("Credit account does not exist")
override fun updateAccountBalance(
userId: UUID,
newBalance: Long,
now: Instant,
): CreditAccount = CreditAccount(userId, newBalance, now).also { accounts[userId] = it }
override fun findLedgerEntry(userId: UUID, idempotencyKey: String): LedgerEntry? =
ledger.singleOrNull {
it.userId == userId && it.idempotencyKey == idempotencyKey
}
override fun insertLedgerEntry(entry: LedgerEntry) {
check(findLedgerEntry(entry.userId, entry.idempotencyKey) == null)
ledger += entry
}
override fun listLedgerEntries(userId: UUID, limit: Int): List<LedgerEntry> =
ledger.filter { it.userId == userId }
.sortedWith(compareByDescending<LedgerEntry> { it.createdAt }.thenByDescending { it.id })
.take(limit)
override fun insertUsageRecord(record: CreditUsageRecord) {
check(usageRecords.none { it.reservationId == record.reservationId })
usageRecords += record
}
override fun findReservationByReserveKey(
userId: UUID,
idempotencyKey: String,
): CreditReservation? = reservations.values.singleOrNull {
it.userId == userId && it.reserveIdempotencyKey == idempotencyKey
}
override fun lockReservation(id: UUID): CreditReservation? = reservations[id]
override fun insertReservation(reservation: CreditReservation) {
check(reservations.putIfAbsent(reservation.id, reservation) == null)
}
override fun updateReservation(reservation: CreditReservation) {
check(reservations.containsKey(reservation.id))
reservations[reservation.id] = reservation
}
override fun findRateVersion(id: UUID): CreditRateVersion? = rates[id]
override fun findEffectiveRate(
kind: UsageKind,
provider: String,
model: String,
at: Instant,
): CreditRateVersion? = rates.values
.filter {
it.kind == kind &&
it.provider == provider &&
it.model == model &&
it.effectiveFrom <= at &&
(it.effectiveUntil == null || it.effectiveUntil > at)
}
.maxByOrNull(CreditRateVersion::effectiveFrom)
override fun listEffectiveRates(at: Instant): List<CreditRateVersion> =
rates.values.filter {
it.effectiveFrom <= at && (it.effectiveUntil == null || it.effectiveUntil > at)
}
}
private inner class Referrals : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? =
codes.values
.filter { it.ownerUserId == ownerUserId }
.filter { campaignId == null || it.campaignId == campaignId }
.maxByOrNull(ReferralCode::createdAt)
override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? =
findCodeByOwner(ownerUserId, campaignId)
override fun findCode(code: String): ReferralCode? =
codes.values.singleOrNull { it.code == code }
override fun insertCodeIfAbsent(code: ReferralCode): Boolean {
if (findCodeByOwner(code.ownerUserId, code.campaignId) != null ||
findCode(code.code) != null
) {
return false
}
codes[code.id] = code
return true
}
override fun findCampaign(id: UUID): ReferralCampaign? = campaigns[id]
override fun listActiveCampaigns(at: Instant): List<ReferralCampaign> =
campaigns.values.filter { it.isActive(at) }.sortedByDescending { it.startsAt }
override fun lockCampaignBudget(campaignId: UUID): ReferralCampaignBudget =
campaignBudgets.getValue(campaignId)
override fun updateCampaignBudget(budget: ReferralCampaignBudget) {
campaignBudgets[budget.campaignId] = budget
}
override fun findBinding(inviteeUserId: UUID): ReferralBinding? = bindings[inviteeUserId]
override fun listBindingsByInviter(
inviterUserId: UUID,
limit: Int,
): List<ReferralBinding> =
bindings.values
.filter { it.inviterUserId == inviterUserId }
.sortedByDescending { it.boundAt }
.take(limit)
override fun lockBinding(inviteeUserId: UUID): ReferralBinding? = bindings[inviteeUserId]
override fun insertBindingIfAbsent(binding: ReferralBinding): Boolean {
if (bindings.containsKey(binding.inviteeUserId)) return false
bindings[binding.inviteeUserId] = binding
return true
}
override fun markRewarded(
bindingId: UUID,
settlementId: UUID,
rewardedAt: Instant,
) {
val entry = bindings.entries.single { it.value.id == bindingId }
entry.setValue(
entry.value.copy(
rewardedAt = rewardedAt,
rewardSettlementId = settlementId,
rewardStatus = ReferralRewardStatus.REWARDED,
),
)
}
override fun markRewardIneligible(bindingId: UUID) {
val entry = bindings.entries.single { it.value.id == bindingId }
entry.setValue(entry.value.copy(rewardStatus = ReferralRewardStatus.INELIGIBLE_BUDGET))
}
}
}
private fun <K, V> MutableMap<K, V>.replaceWith(snapshot: Map<K, V>) {
clear()
putAll(snapshot)
}
private fun <T> MutableList<T>.replaceWith(snapshot: List<T>) {
clear()
addAll(snapshot)
}
@@ -0,0 +1,359 @@
package com.osglab.account.features.gateway.asr
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditMeterPort
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.ports.PendingSettlement
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
import com.osglab.account.features.gateway.providers.volcengine.AsrTransportResult
import com.osglab.account.features.gateway.providers.volcengine.VolcengineStreamingClient
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import com.osglab.account.features.gateway.services.GatewayService
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
class AsrStreamingServiceTest : StringSpec({
"releases credits when the mock upstream fails" {
val fixture = fixture(
upstream = VolcengineStreamingClient { _, _, _ -> throw MockUpstreamFailure() },
)
try {
val session = fixture.service.createSession(PRINCIPAL, "request-asr-1", request())
shouldThrow<MockUpstreamFailure> {
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(byteArrayOf(1, 2)),
DISCARD_OUTPUT,
)
}
fixture.credits.released.shouldContainExactly(RESERVATION_ID)
fixture.credits.settled shouldBe emptyList()
} finally {
fixture.scope.cancel()
}
}
"releases credits when ASR completes without a result" {
val fixture = fixture(
upstream = VolcengineStreamingClient { _, frames, _ ->
frames.collect { }
AsrTransportResult(700, "provider-1", hasResult = false)
},
)
try {
val session = fixture.service.createSession(PRINCIPAL, "request-asr-2", request())
shouldThrow<RuntimeException> {
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(byteArrayOf(1)),
DISCARD_OUTPUT,
)
}
fixture.credits.released.shouldContainExactly(RESERVATION_ID)
fixture.usage.manualReview shouldBe false
} finally {
fixture.scope.cancel()
}
}
"settles successful ASR by provider milliseconds" {
val fixture = fixture(
upstream = VolcengineStreamingClient { _, frames, _ ->
frames.collect { }
AsrTransportResult(725, "provider-2", hasResult = true)
},
)
try {
val session = fixture.service.createSession(PRINCIPAL, "request-asr-3", request())
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(byteArrayOf(1, 2, 3)),
DISCARD_OUTPUT,
)
fixture.credits.settled.shouldContainExactly(RESERVATION_ID to 725L)
fixture.credits.released shouldBe emptyList()
} finally {
fixture.scope.cancel()
}
}
"binds a streaming session to the grant that reserved it" {
val fixture = fixture(
upstream = VolcengineStreamingClient { _, frames, _ ->
frames.collect { }
AsrTransportResult(500, "provider-grant")
},
)
try {
val session = fixture.service.createSession(PRINCIPAL, "request-asr-grant", request())
shouldThrow<AsrSessionNotFoundException> {
fixture.service.stream(
session.sessionId,
PRINCIPAL.copy(grantId = "other-grant"),
flowOf(byteArrayOf(1)),
DISCARD_OUTPUT,
)
}
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(byteArrayOf(1)),
DISCARD_OUTPUT,
)
fixture.credits.settled.shouldContainExactly(RESERVATION_ID to 500L)
} finally {
fixture.scope.cancel()
}
}
"rejects oversized audio frames before forwarding them" {
var upstreamFrames = 0
val fixture = fixture(
upstream = VolcengineStreamingClient { _, frames, _ ->
frames.collect { upstreamFrames += 1 }
AsrTransportResult(500, "provider-3")
},
limits = AsrStreamingLimits(maxFrameBytes = 2),
)
try {
val session = fixture.service.createSession(PRINCIPAL, "request-asr-4", request())
shouldThrow<IllegalArgumentException> {
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(byteArrayOf(1, 2, 3)),
DISCARD_OUTPUT,
)
}
upstreamFrames shouldBe 0
fixture.credits.released.shouldContainExactly(RESERVATION_ID)
} finally {
fixture.scope.cancel()
}
}
"does not allow a large frame to exceed declared PCM duration" {
var upstreamFrames = 0
val fixture = fixture(
upstream = VolcengineStreamingClient { _, frames, _ ->
frames.collect { upstreamFrames += 1 }
AsrTransportResult(1, "provider-duration")
},
)
try {
val session = fixture.service.createSession(
PRINCIPAL,
"request-asr-duration",
request().copy(estimatedDurationMillis = 1),
)
shouldThrow<IllegalArgumentException> {
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(ByteArray(33)),
DISCARD_OUTPUT,
)
}
upstreamFrames shouldBe 0
fixture.credits.released.shouldContainExactly(RESERVATION_ID)
} finally {
fixture.scope.cancel()
}
}
"reserves the full policy duration for compressed streaming audio" {
val fixture = fixture(
upstream = VolcengineStreamingClient { _, frames, _ ->
frames.collect { }
AsrTransportResult(500, "provider-compressed")
},
)
try {
fixture.service.createSession(
PRINCIPAL,
"request-asr-compressed",
request().copy(format = "mp3", codec = "raw"),
)
fixture.credits.lastEstimatedUnits shouldBe GatewayLimits.MAX_AUDIO_MILLIS
} finally {
fixture.scope.cancel()
}
}
"releases credits when a streaming ASR call is cancelled" {
val started = CompletableDeferred<Unit>()
val fixture = fixture(
upstream = VolcengineStreamingClient { _, _, _ ->
started.complete(Unit)
awaitCancellation()
},
)
try {
val session = fixture.service.createSession(PRINCIPAL, "request-asr-5", request())
coroutineScope {
val call = launch {
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(byteArrayOf(1, 2, 3)),
DISCARD_OUTPUT,
)
}
started.await()
call.cancelAndJoin()
}
fixture.credits.released.shouldContainExactly(RESERVATION_ID)
fixture.credits.settled shouldBe emptyList()
} finally {
fixture.scope.cancel()
}
}
})
private data class Fixture(
val service: AsrStreamingService,
val credits: FakeAsrCredits,
val usage: FakeGatewayUsage,
val scope: CoroutineScope,
)
private fun fixture(
upstream: VolcengineStreamingClient,
limits: AsrStreamingLimits = AsrStreamingLimits(),
): Fixture {
val credits = FakeAsrCredits()
val usage = FakeGatewayUsage()
val gateway = GatewayService(
catalog = ProviderCatalog(listOf(DummyAsrProvider)),
credits = credits,
grants = { _, _ -> true },
usageRecords = usage,
)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
return Fixture(
AsrStreamingService(gateway, upstream, scope, limits),
credits,
usage,
scope,
)
}
private fun request() = CreateAsrSessionRequest(estimatedDurationMillis = 1_000)
private class FakeAsrCredits : CreditMeterPort {
val settled = mutableListOf<Pair<String, Long>>()
val released = mutableListOf<String>()
var lastEstimatedUnits: Long? = null
override suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
): CreditReservation {
lastEstimatedUnits = estimatedUnits
return CreditReservation(RESERVATION_ID, estimatedUnits)
}
override suspend fun settle(reservationId: String, actualUnits: Long) {
settled += reservationId to actualUnits
}
override suspend fun settle(reservationId: String, usage: ProviderUsage) {
settle(reservationId, usage.units)
}
override suspend fun release(reservationId: String) {
released += reservationId
}
}
private class FakeGatewayUsage : GatewayUsagePort {
var manualReview = false
override suspend fun claim(metadata: ProviderRequestMetadata) = Unit
override suspend fun markStarted(accountId: String, requestId: String) = Unit
override suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
) = Unit
override suspend fun markSucceeded(
accountId: String,
requestId: String,
usage: ProviderUsage,
) = Unit
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) = Unit
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) {
manualReview = true
}
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> = emptyList()
}
private object DummyAsrProvider : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "test-asr",
capabilities = setOf(GatewayCapability.ASR),
streaming = true,
usageMeter = UsageMeter.AUDIO_MILLISECOND,
)
override fun accepts(request: ProviderRequest): Boolean =
request.capability == GatewayCapability.ASR
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage =
error("Streaming tests call the upstream transport directly")
}
private val PRINCIPAL = GatewayPrincipal(
userId = "user-1",
grantId = "grant-1",
scopes = setOf(GatewayCapability.ASR),
)
private val DISCARD_OUTPUT = ProviderOutput { }
private const val RESERVATION_ID = "reservation-asr"
private class MockUpstreamFailure : RuntimeException()
@@ -0,0 +1,45 @@
package com.osglab.account.features.gateway.models
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
class TextRequestPolicyTest : StringSpec({
"rejects blank and oversized input" {
shouldThrow<IllegalArgumentException> {
TextRequestPolicy.validate(TextGatewayRequest(input = " "))
}
shouldThrow<IllegalArgumentException> {
TextRequestPolicy.validate(
TextGatewayRequest(input = "a".repeat(GatewayLimits.MAX_TEXT_INPUT_CHARS + 1)),
)
}
}
"rejects oversized context and output settings" {
shouldThrow<IllegalArgumentException> {
TextRequestPolicy.validate(
TextGatewayRequest(
input = "hello",
context = "a".repeat(GatewayLimits.MAX_TEXT_CONTEXT_CHARS + 1),
),
)
}
shouldThrow<IllegalArgumentException> {
TextRequestPolicy.validate(
TextGatewayRequest(
input = "hello",
maxOutputTokens = GatewayLimits.MAX_OUTPUT_TOKENS + 1,
),
)
}
}
"requires agent responses to be buffered for schema validation" {
shouldThrow<IllegalArgumentException> {
TextRequestPolicy.validate(
TextGatewayRequest(input = "plan this", stream = true),
GatewayCapability.AGENT,
)
}
}
})
@@ -0,0 +1,183 @@
package com.osglab.account.features.gateway.providers.deepseek
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.TextProviderRequest
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
class DeepSeekClientTest : StringSpec({
"prefers provider token usage" {
val client = client(
"""{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}}""",
)
try {
val usage = KtorDeepSeekClient(client, CONFIG).complete(request(), DISCARD_OUTPUT)
usage.units shouldBe 13L
usage.inputUnits shouldBe 10L
usage.outputUnits shouldBe 3L
} finally {
client.close()
}
}
"fails closed when usage is absent" {
val client = client("""{"choices":[{"message":{"content":"ok"}}]}""")
var emitted = false
try {
shouldThrow<DeepSeekProviderException> {
KtorDeepSeekClient(client, CONFIG).complete(
request(),
ProviderOutput { emitted = true },
)
}
emitted shouldBe false
} finally {
client.close()
}
}
"meters provider input and output tokens from a streamed response" {
val response = """
data: {"choices":[{"delta":{"content":"ok"}}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
data: {"choices":[],"usage":{"prompt_tokens":11,"completion_tokens":4,"total_tokens":15}}
data: [DONE]
""".trimIndent()
val client = client(response, ContentType.Text.EventStream)
val emitted = mutableListOf<ByteArray>()
try {
val usage = KtorDeepSeekClient(client, CONFIG).complete(
request().copy(stream = true),
ProviderOutput { bytes -> emitted += bytes },
)
usage.units shouldBe 15L
usage.inputUnits shouldBe 11L
usage.outputUnits shouldBe 4L
emitted.isNotEmpty() shouldBe true
} finally {
client.close()
}
}
"rejects a stream that closes without the DONE marker" {
val response = """
data: {"choices":[{"delta":{"content":"partial"}}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
data: {"choices":[],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}
""".trimIndent()
val client = client(response, ContentType.Text.EventStream)
try {
shouldThrow<DeepSeekProviderException> {
KtorDeepSeekClient(client, CONFIG).complete(
request().copy(stream = true),
DISCARD_OUTPUT,
)
}
} finally {
client.close()
}
}
"does not forward malformed provider SSE data" {
val client = client("data: not-json\n\n", ContentType.Text.EventStream)
var emitted = false
try {
shouldThrow<DeepSeekProviderException> {
KtorDeepSeekClient(client, CONFIG).complete(
request().copy(stream = true),
ProviderOutput { emitted = true },
)
}
emitted shouldBe false
} finally {
client.close()
}
}
"rejects a successful response with the wrong content type before forwarding" {
val client = client("<html>upstream error</html>", ContentType.Text.Html)
var emitted = false
try {
shouldThrow<DeepSeekProviderException> {
KtorDeepSeekClient(client, CONFIG).complete(
request(),
ProviderOutput { emitted = true },
)
}
emitted shouldBe false
} finally {
client.close()
}
}
"rejects an unstructured agent response before forwarding it" {
val client = client("""{"choices":[{"message":{"content":"not-json"}}]}""")
var emitted = false
try {
shouldThrow<DeepSeekProviderException> {
KtorDeepSeekClient(client, CONFIG).complete(
request(capability = GatewayCapability.AGENT),
ProviderOutput { emitted = true },
)
}
emitted shouldBe false
} finally {
client.close()
}
}
})
private fun client(
responseBody: String,
contentType: ContentType = ContentType.Application.Json,
) = HttpClient(
MockEngine {
respond(
content = responseBody,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, contentType.toString()),
)
},
) {
install(ContentNegotiation) {
json(Json { explicitNulls = false })
}
}
private fun request(capability: GatewayCapability = GatewayCapability.AI) = TextProviderRequest(
requestId = "deepseek-request",
capability = capability,
input = "hello",
context = null,
maxOutputTokens = 32,
temperature = 0.2,
stream = false,
)
private val CONFIG = DeepSeekConfig(
endpoint = "https://api.deepseek.com/v1",
apiKey = "test-key",
model = "configured-model",
)
private val DISCARD_OUTPUT = ProviderOutput { }
@@ -0,0 +1,144 @@
package com.osglab.account.features.gateway.providers.volcengine
import com.osglab.account.features.gateway.models.AsrGatewayOptions
import com.osglab.account.features.gateway.models.AudioDurationPolicy
import com.osglab.account.features.gateway.models.GatewayLimits
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.io.ByteArrayInputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.GZIPInputStream
class SaucV3ProtocolTest : StringSpec({
"encodes v3 full and final audio requests with the documented v1 binary header" {
val codec = SaucV3Codec()
val fullPayload = """{"audio":{"format":"pcm"}}""".encodeToByteArray()
val full = codec.fullClientRequest(fullPayload)
val finalAudio = codec.audioRequest(byteArrayOf(1, 2, 3), isLast = true)
(full[0].toInt() and 0xff) shouldBe 0x11
(full[1].toInt() and 0xff) shouldBe 0x10
(full[2].toInt() and 0xff) shouldBe 0x11
inflatePayload(full) shouldBe fullPayload
(finalAudio[0].toInt() and 0xff) shouldBe 0x11
(finalAudio[1].toInt() and 0xff) shouldBe 0x22
(finalAudio[2].toInt() and 0xff) shouldBe 0x01
inflatePayload(finalAudio) shouldBe byteArrayOf(1, 2, 3)
}
"accepts strictly increasing positive sequences and a negative final sequence" {
val codec = SaucV3Codec()
val validator = SaucSequenceValidator()
validator.accept(codec.decodeServerFrame(serverFrame(1, false, """{"result":{}}""")))
val final = codec.decodeServerFrame(
serverFrame(-2, true, """{"audio_info":{"duration":1234}}"""),
)
validator.accept(final)
extractFinalDuration(final) shouldBe 1_234L
}
"rejects a positive final sequence" {
val frame = SaucV3Codec().decodeServerFrame(
serverFrame(1, true, """{"audio_info":{"duration":100}}"""),
)
shouldThrow<SaucProtocolException> {
SaucSequenceValidator().accept(frame)
}
}
"rejects out of order server sequences" {
val codec = SaucV3Codec()
val validator = SaucSequenceValidator()
validator.accept(codec.decodeServerFrame(serverFrame(1, false, "{}")))
shouldThrow<SaucProtocolException> {
validator.accept(codec.decodeServerFrame(serverFrame(3, false, "{}")))
}
}
"only the final frame can provide billable duration" {
val nonFinal = SaucV3Codec().decodeServerFrame(
serverFrame(1, false, """{"audio_info":{"duration":1}}"""),
)
shouldThrow<VolcengineUsageException> {
extractFinalDuration(nonFinal)
}
}
"final duration is required and bounded" {
val codec = SaucV3Codec()
shouldThrow<VolcengineUsageException> {
extractFinalDuration(codec.decodeServerFrame(serverFrame(-1, true, "{}")))
}
shouldThrow<VolcengineUsageException> {
extractFinalDuration(
codec.decodeServerFrame(
serverFrame(
-1,
true,
"""{"audio_info":{"duration":${GatewayLimits.MAX_AUDIO_MILLIS + 1}}}""",
),
),
)
}
}
"requires actual recognized text before billing ASR output" {
hasRecognitionResult("""{"result":{"text":"hello"}}""".encodeToByteArray()) shouldBe true
hasRecognitionResult(
"""{"result":{"utterances":[{"text":"hello"}]}}""".encodeToByteArray(),
) shouldBe true
hasRecognitionResult("""{"result":{"text":"","utterances":[]}}""".encodeToByteArray()) shouldBe false
hasRecognitionResult("""{"result":{"definite":true}}""".encodeToByteArray()) shouldBe false
}
"PCM reservation is derived from bytes and rejects a forged short duration" {
val oneSecondPcmBytes = 16_000 * 2
val forged = AsrGatewayOptions(estimatedDurationMillis = 1)
shouldThrow<IllegalArgumentException> {
AudioDurationPolicy.reservationMillis(oneSecondPcmBytes, forged)
}
AudioDurationPolicy.reservationMillis(
oneSecondPcmBytes,
forged.copy(estimatedDurationMillis = 1_000),
) shouldBe 1_000L
}
"compressed audio reserves the full policy boundary" {
val options = AsrGatewayOptions(
format = "ogg",
codec = "opus",
estimatedDurationMillis = 20_000,
)
AudioDurationPolicy.reservationMillis(64_000, options) shouldBe
GatewayLimits.MAX_AUDIO_MILLIS
}
})
private fun serverFrame(sequence: Int, final: Boolean, payload: String): ByteArray {
val bytes = payload.encodeToByteArray()
return ByteBuffer.allocate(12 + bytes.size)
.order(ByteOrder.BIG_ENDIAN)
.put(0x11)
.put(if (final) 0x93.toByte() else 0x91.toByte())
.put(0x10)
.put(0)
.putInt(sequence)
.putInt(bytes.size)
.put(bytes)
.array()
}
private fun inflatePayload(frame: ByteArray): ByteArray {
val size = ByteBuffer.wrap(frame, 4, 4).order(ByteOrder.BIG_ENDIAN).int
return GZIPInputStream(ByteArrayInputStream(frame, 8, size)).use { it.readAllBytes() }
}
@@ -0,0 +1,158 @@
package com.osglab.account.features.gateway.routes
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.CreditReservationPort
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.ports.PendingSettlement
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import com.osglab.account.features.gateway.services.GatewayService
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
class GatewayRequestIdTest : StringSpec({
"requires X-Request-ID before invoking a billable provider" {
val provider = RequestIdProvider()
testApplication {
application { gatewayTestApplication(provider) }
val missing = client.post("/v1/gateway/llm/ai") {
contentType(ContentType.Application.Json)
setBody("""{"input":"hello","maxOutputTokens":8}""")
}
val invalid = client.post("/v1/gateway/llm/ai") {
header("X-Request-ID", "bad")
contentType(ContentType.Application.Json)
setBody("""{"input":"hello","maxOutputTokens":8}""")
}
missing.status shouldBe HttpStatusCode.BadRequest
invalid.status shouldBe HttpStatusCode.BadRequest
provider.calls shouldBe 0
}
}
"uses X-Request-ID as the account-scoped provider idempotency key" {
val provider = RequestIdProvider()
testApplication {
application { gatewayTestApplication(provider) }
val response = client.post("/v1/gateway/llm/ai") {
header("X-Request-ID", "request-route-123")
contentType(ContentType.Application.Json)
setBody("""{"input":"hello","maxOutputTokens":8}""")
}
response.status shouldBe HttpStatusCode.OK
provider.lastRequestId shouldBe "request-route-123"
}
}
})
private fun io.ktor.server.application.Application.gatewayTestApplication(provider: RequestIdProvider) {
install(ContentNegotiation) {
json(Json { explicitNulls = false })
}
val service = GatewayService(
catalog = ProviderCatalog(listOf(provider)),
credits = RequestIdCredits,
grants = { _, _ -> true },
usageRecords = RequestIdUsage,
)
routing {
configureGatewayRoutes(
service = service,
appIdentity = { null },
gatewayIdentity = { REQUEST_ID_PRINCIPAL },
)
}
}
private class RequestIdProvider : GatewayProvider {
var calls = 0
var lastRequestId: String? = null
override val descriptor = ProviderDescriptor(
id = "request-id-provider",
capabilities = setOf(GatewayCapability.AI),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
calls += 1
lastRequestId = request.requestId
output.emit("""{"result":"ok"}""".encodeToByteArray())
return ProviderUsage(
meter = UsageMeter.LLM_TOKEN,
units = 3,
inputUnits = 2,
outputUnits = 1,
)
}
}
private object RequestIdCredits : CreditReservationPort {
override suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
) = CreditReservation("00000000-0000-0000-0000-000000000002", estimatedUnits)
override suspend fun settle(reservationId: String, actualUnits: Long) = Unit
override suspend fun release(reservationId: String) = Unit
}
private object RequestIdUsage : GatewayUsagePort {
override suspend fun claim(metadata: ProviderRequestMetadata) = Unit
override suspend fun markStarted(accountId: String, requestId: String) = Unit
override suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
) = Unit
override suspend fun markSucceeded(
accountId: String,
requestId: String,
usage: ProviderUsage,
) = Unit
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) = Unit
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) = Unit
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> = emptyList()
}
private val REQUEST_ID_PRINCIPAL = GatewayPrincipal(
userId = "00000000-0000-0000-0000-000000000001",
grantId = "00000000-0000-0000-0000-000000000003",
scopes = setOf(GatewayCapability.AI),
)
@@ -0,0 +1,228 @@
package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.GatewaySettings
import com.osglab.account.features.gateway.models.CreateGatewayGrantRequest
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayGrant
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
import com.osglab.account.features.gateway.ports.GatewayRefreshRotationResult
import com.osglab.account.features.gateway.ports.NewGatewayGrant
import com.osglab.account.features.gateway.ports.StoredGatewayRefresh
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
class GatewayGrantServiceTest : StringSpec({
"issues a scope-limited token and stores only the refresh hash" {
val repository = FakeGrantRepository()
val service = grantService(repository)
val tokens = service.create(
PRINCIPAL,
CreateGatewayGrantRequest(setOf(GatewayCapability.POLISH), 3_600),
"create-request-1",
)
repository.refreshes.values.single().tokenHash shouldBe repository.hash(tokens.refreshToken)
repository.refreshes.values.single().tokenHash.contains(tokens.refreshToken) shouldBe false
service.authenticate(tokens.accessToken)?.scopes shouldBe setOf(GatewayCapability.POLISH)
}
"rejects scopes not held by the issuing identity" {
val repository = FakeGrantRepository()
val service = grantService(repository)
shouldThrow<IllegalArgumentException> {
service.create(
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
CreateGatewayGrantRequest(setOf(GatewayCapability.AI)),
"create-request-scope",
)
}
repository.grants.size shouldBe 0
}
"refresh rotation is idempotent for the same operation key and detects reuse" {
val repository = FakeGrantRepository()
val service = grantService(repository)
val created = service.create(
PRINCIPAL,
CreateGatewayGrantRequest(setOf(GatewayCapability.AI)),
"create-request-2",
)
val rotated = service.refresh(created.refreshToken, "refresh-request-1")
val replay = service.refresh(created.refreshToken, "refresh-request-1")
replay.refreshToken shouldBe rotated.refreshToken
shouldThrow<GatewayRefreshTokenReuseException> {
service.refresh(created.refreshToken, "refresh-request-2")
}
service.authenticate(rotated.accessToken) shouldBe null
}
"grant creation replay returns the same refresh credential without a duplicate grant" {
val repository = FakeGrantRepository()
val service = grantService(repository)
val request = CreateGatewayGrantRequest(setOf(GatewayCapability.ASR))
val first = service.create(PRINCIPAL, request, "create-request-4")
val replay = service.create(PRINCIPAL, request, "create-request-4")
replay.grantId shouldBe first.grantId
replay.refreshToken shouldBe first.refreshToken
repository.grants.size shouldBe 1
}
"revocation immediately invalidates an otherwise unexpired access token" {
val repository = FakeGrantRepository()
val service = grantService(repository)
val tokens = service.create(
PRINCIPAL,
CreateGatewayGrantRequest(setOf(GatewayCapability.AGENT)),
"create-request-3",
)
service.revoke(PRINCIPAL, tokens.grantId) shouldBe true
service.authenticate(tokens.accessToken) shouldBe null
}
})
private fun grantService(repository: GatewayGrantRepository) = GatewayGrantService(
repository = repository,
settings = GatewaySettings(
issuer = "osg-test",
audience = "osg-gateway-test",
accessTokenHmacSecret = ByteArray(32) { 1 },
refreshTokenHmacSecret = ByteArray(32) { 2 },
accessTokenLifetime = Duration.ofMinutes(5),
refreshTokenLifetime = Duration.ofDays(30),
maximumGrantLifetime = Duration.ofDays(90),
),
clock = Clock.fixed(NOW, ZoneOffset.UTC),
)
private class FakeGrantRepository : GatewayGrantRepository {
data class Refresh(
val tokenId: String,
val grantId: String,
val familyId: String,
val tokenHash: String,
val expiresAt: Instant,
var replacedById: String? = null,
var rotationKey: String? = null,
var revoked: Boolean = false,
)
val grants = mutableMapOf<String, GatewayGrant>()
val refreshes = mutableMapOf<String, Refresh>()
private val createKeys = mutableMapOf<Pair<String, String>, String>()
override suspend fun create(grant: NewGatewayGrant, now: Instant): StoredGatewayRefresh {
val existingId = createKeys[grant.accountId to grant.idempotencyKey]
if (existingId != null) {
val existing = grants.getValue(existingId)
val refresh = refreshes.values.single {
it.grantId == existingId && it.replacedById == null && !it.revoked
}
return refresh.stored(existing)
}
val storedGrant = GatewayGrant(grant.id, grant.accountId, grant.scopes, grant.expiresAt)
grants[grant.id] = storedGrant
createKeys[grant.accountId to grant.idempotencyKey] = grant.id
refreshes[grant.refreshTokenHash] = Refresh(
tokenId = grant.refreshTokenId,
grantId = grant.id,
familyId = grant.refreshFamilyId,
tokenHash = grant.refreshTokenHash,
expiresAt = grant.refreshExpiresAt,
)
return refreshes.getValue(grant.refreshTokenHash).stored(storedGrant)
}
override suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): GatewayRefreshRotationResult {
val current = refreshes[currentTokenHash] ?: return GatewayRefreshRotationResult.Invalid
val grant = grants.getValue(current.grantId)
current.replacedById?.let { replacementId ->
if (current.rotationKey == rotationIdempotencyKey) {
val replacement = refreshes.values.single { it.tokenId == replacementId }
return GatewayRefreshRotationResult.Rotated(replacement.stored(grant))
}
refreshes.values.filter { it.familyId == current.familyId }.forEach { it.revoked = true }
grants[current.grantId] = grant.copy(revokedAt = now)
return GatewayRefreshRotationResult.ReuseDetected
}
if (current.revoked || !current.expiresAt.isAfter(now) || grant.revokedAt != null) {
return GatewayRefreshRotationResult.Invalid
}
val replacement = Refresh(
tokenId = newTokenId,
grantId = current.grantId,
familyId = current.familyId,
tokenHash = newTokenHash,
expiresAt = minOf(newExpiresAt, grant.expiresAt),
)
refreshes[newTokenHash] = replacement
current.replacedById = newTokenId
current.rotationKey = rotationIdempotencyKey
current.revoked = true
return GatewayRefreshRotationResult.Rotated(replacement.stored(grant))
}
override suspend fun revoke(accountId: String, grantId: String, now: Instant): Boolean {
val grant = grants[grantId]?.takeIf { it.accountId == accountId && it.revokedAt == null }
?: return false
grants[grantId] = grant.copy(revokedAt = now)
refreshes.values.filter { it.grantId == grantId }.forEach { it.revoked = true }
return true
}
override suspend fun findActive(
grantId: String,
accountId: String,
scopes: Set<GatewayCapability>,
now: Instant,
): GatewayGrant? = grants[grantId]?.takeIf {
it.accountId == accountId &&
it.scopes == scopes &&
it.revokedAt == null &&
it.expiresAt.isAfter(now)
}
override suspend fun isAllowed(accountId: String, capability: GatewayCapability): Boolean =
grants.values.any {
it.accountId == accountId && capability in it.scopes && it.revokedAt == null
}
fun hash(token: String): String =
java.security.MessageDigest.getInstance("SHA-256")
.digest(token.encodeToByteArray())
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
private fun Refresh.stored(grant: GatewayGrant) = StoredGatewayRefresh(
grant = grant,
tokenId = tokenId,
familyId = familyId,
expiresAt = expiresAt,
)
}
private val PRINCIPAL = GatewayPrincipal(
userId = "00000000-0000-0000-0000-000000000001",
scopes = GatewayCapability.entries.toSet(),
)
private val NOW = Instant.parse("2026-08-16T00:00:00Z")
@@ -0,0 +1,231 @@
package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditMeterPort
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.ports.PendingSettlement
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
import com.osglab.account.features.gateway.ports.ProviderRequestState
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.delay
class GatewayReplayStateTest : StringSpec({
"an account-scoped replay never calls upstream twice" {
val credits = ReplayCredits()
val records = ReplayRecords()
val provider = ReplayProvider()
val service = replayService(credits, records, provider)
service.execute(PRINCIPAL_A, replayRequest(), DISCARD)
shouldThrow<GatewayRequestAlreadyClaimedException> {
service.execute(PRINCIPAL_A, replayRequest(), DISCARD)
}
provider.calls shouldBe 1
}
"the same request ID is independent across accounts" {
val records = ReplayRecords()
val provider = ReplayProvider()
val service = replayService(ReplayCredits(), records, provider)
service.execute(PRINCIPAL_A, replayRequest(), DISCARD)
service.execute(PRINCIPAL_B, replayRequest(), DISCARD)
provider.calls shouldBe 2
records.state(PRINCIPAL_A.userId) shouldBe ProviderRequestState.SETTLED
records.state(PRINCIPAL_B.userId) shouldBe ProviderRequestState.SETTLED
}
"whole-call timeout releases only the incomplete provider call" {
val credits = ReplayCredits()
val records = ReplayRecords()
val service = replayService(
credits,
records,
ReplayProvider(delayMillis = 100),
timeoutMillis = 10,
)
shouldThrow<kotlinx.coroutines.TimeoutCancellationException> {
service.execute(PRINCIPAL_A, replayRequest(), DISCARD)
}
credits.releases shouldBe 1
records.state(PRINCIPAL_A.userId) shouldBe ProviderRequestState.RELEASED
}
"completed inconsistent provider usage releases the reservation" {
val credits = ReplayCredits()
val records = ReplayRecords()
val service = replayService(
credits,
records,
ReplayProvider(
usage = VALID_USAGE.copy(units = VALID_USAGE.units + 1),
),
)
shouldThrow<GatewayUsagePolicyException> {
service.execute(PRINCIPAL_A, replayRequest(), DISCARD)
}
credits.releases shouldBe 1
records.state(PRINCIPAL_A.userId) shouldBe ProviderRequestState.RELEASED
}
"marks a claimed request for review when release also fails" {
val credits = ReplayCredits(failRelease = true)
val records = ReplayRecords(failStart = true)
val service = replayService(credits, records, ReplayProvider())
shouldThrow<StartRecordingFailure> {
service.execute(PRINCIPAL_A, replayRequest(), DISCARD)
}
records.state(PRINCIPAL_A.userId) shouldBe ProviderRequestState.MANUAL_REVIEW
}
})
private fun replayService(
credits: CreditMeterPort,
records: GatewayUsagePort,
provider: GatewayProvider,
timeoutMillis: Long = 1_000,
) = GatewayService(
catalog = ProviderCatalog(listOf(provider)),
credits = credits,
grants = { _, _ -> true },
usageRecords = records,
llmProviderTimeoutMillis = timeoutMillis,
asrProviderTimeoutMillis = timeoutMillis,
)
private fun replayRequest() = TextProviderRequest(
requestId = REPLAY_ID,
capability = GatewayCapability.AI,
input = "hello",
context = null,
maxOutputTokens = 32,
temperature = 0.2,
stream = false,
)
private class ReplayCredits(
private val failRelease: Boolean = false,
) : CreditMeterPort {
private val reservations = mutableMapOf<Pair<String, String>, CreditReservation>()
var releases = 0
override suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
): CreditReservation = reservations.getOrPut(accountId to requestId) {
CreditReservation("reservation-$accountId-$requestId", estimatedUnits)
}
override suspend fun settle(reservationId: String, actualUnits: Long) = Unit
override suspend fun release(reservationId: String) {
releases += 1
if (failRelease) throw ReleaseFailure()
}
}
private class ReplayProvider(
private val delayMillis: Long = 0,
private val usage: ProviderUsage = VALID_USAGE,
) : GatewayProvider {
var calls = 0
override val descriptor = ProviderDescriptor(
id = "replay-provider",
capabilities = setOf(GatewayCapability.AI),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
calls += 1
if (delayMillis > 0) delay(delayMillis)
return usage
}
}
private class ReplayRecords(
private val failStart: Boolean = false,
) : GatewayUsagePort {
private data class Record(
val metadata: ProviderRequestMetadata,
var state: ProviderRequestState,
var usage: ProviderUsage? = null,
)
private val values = mutableMapOf<Pair<String, String>, Record>()
override suspend fun claim(metadata: ProviderRequestMetadata) {
val key = metadata.accountId to metadata.requestId
values[key]?.let { throw GatewayRequestAlreadyClaimedException(it.state) }
values[key] = Record(metadata, ProviderRequestState.CLAIMED)
}
override suspend fun markStarted(accountId: String, requestId: String) {
if (failStart) throw StartRecordingFailure()
values.getValue(accountId to requestId).state = ProviderRequestState.STARTED
}
override suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
) {
values.getValue(accountId to requestId).apply {
state = ProviderRequestState.SETTLEMENT_PENDING
this.usage = usage
}
}
override suspend fun markSucceeded(accountId: String, requestId: String, usage: ProviderUsage) {
values.getValue(accountId to requestId).state = ProviderRequestState.SETTLED
}
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) {
values.getValue(accountId to requestId).state = ProviderRequestState.RELEASED
}
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) {
values.getValue(accountId to requestId).state = ProviderRequestState.MANUAL_REVIEW
}
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> = emptyList()
fun state(accountId: String): ProviderRequestState =
values.getValue(accountId to REPLAY_ID).state
}
private val PRINCIPAL_A = GatewayPrincipal("account-a", scopes = setOf(GatewayCapability.AI))
private val PRINCIPAL_B = GatewayPrincipal("account-b", scopes = setOf(GatewayCapability.AI))
private val DISCARD = ProviderOutput { }
private val VALID_USAGE = ProviderUsage(
meter = UsageMeter.LLM_TOKEN,
units = 7,
inputUnits = 5,
outputUnits = 2,
)
private const val REPLAY_ID = "request-replay"
private class StartRecordingFailure : RuntimeException()
private class ReleaseFailure : RuntimeException()
@@ -0,0 +1,289 @@
package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditMeterPort
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.ports.PendingSettlement
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
import com.osglab.account.features.gateway.ports.ProviderUsageEstimate
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
class GatewayServiceBillingTest : StringSpec({
"releases a reservation when the mock upstream fails" {
val credits = FakeCredits()
val service = service(credits, FakeProvider(fail = true))
shouldThrow<ProviderFailure> {
service.execute(PRINCIPAL, request(), DISCARD_OUTPUT)
}
credits.settled shouldBe emptyList()
credits.released.shouldContainExactly(RESERVATION_ID)
}
"releases a reservation when the provider reports an empty result" {
val credits = FakeCredits()
val service = service(credits, EmptyResultProvider())
shouldThrow<EmptyResultFailure> {
service.execute(PRINCIPAL, request(), DISCARD_OUTPUT)
}
credits.released.shouldContainExactly(RESERVATION_ID)
}
"settles successful token usage and does not release" {
val credits = FakeCredits()
val service = service(credits, FakeProvider())
service.execute(PRINCIPAL, request(), DISCARD_OUTPUT)
credits.settled.shouldContainExactly(RESERVATION_ID to 21L)
credits.released shouldBe emptyList()
credits.lastEstimate?.meter shouldBe UsageMeter.LLM_TOKEN
credits.lastEstimate?.inputUnits shouldBe 261L
credits.lastEstimate?.outputUnits shouldBe 32L
}
"releases a reservation when a provider call is cancelled" {
val credits = FakeCredits()
val started = CompletableDeferred<Unit>()
val provider = object : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "cancellable-provider",
capabilities = setOf(GatewayCapability.AI),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
override suspend fun execute(
request: ProviderRequest,
output: ProviderOutput,
): ProviderUsage {
started.complete(Unit)
awaitCancellation()
}
}
val service = service(credits, provider)
coroutineScope {
val call = launch { service.execute(PRINCIPAL, request(), DISCARD_OUTPUT) }
started.await()
call.cancelAndJoin()
}
credits.released.shouldContainExactly(RESERVATION_ID)
credits.settled shouldBe emptyList()
}
"keeps the reservation frozen when settlement fails after upstream success" {
val credits = FakeCredits(failSettle = true)
val service = service(credits, FakeProvider())
service.execute(PRINCIPAL, request(), DISCARD_OUTPUT)
credits.settled.shouldContainExactly(RESERVATION_ID to 21L)
credits.released shouldBe emptyList()
}
"repeated settlement delegates idempotency to the credit port" {
val credits = FakeCredits(idempotent = true)
val usageRecords = FakeUsageRecords(
pending = mutableListOf(
PendingSettlement(
requestId = "request-123",
accountId = PRINCIPAL.userId,
reservationId = RESERVATION_ID,
usage = TOKEN_USAGE,
),
),
)
val reconciliation = GatewayReconciliationService(credits, usageRecords)
reconciliation.reconcile()
reconciliation.reconcile()
credits.settled.shouldContainExactly(RESERVATION_ID to 21L)
}
"delegates an explicit settled-call reversal to billing refund" {
val credits = FakeCredits()
GatewayRefundService(credits).refund(RESERVATION_ID)
credits.refunded.shouldContainExactly(RESERVATION_ID)
}
"rejects a capability outside the gateway token scope before billing" {
val credits = FakeCredits()
val service = service(credits, FakeProvider())
shouldThrow<GatewayAccessDeniedException> {
service.execute(
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
request(),
DISCARD_OUTPUT,
)
}
credits.reserveCalls shouldBe 0
}
})
private fun service(
credits: CreditMeterPort,
provider: GatewayProvider,
usageRecords: GatewayUsagePort = FakeUsageRecords(),
): GatewayService = GatewayService(
catalog = ProviderCatalog(listOf(provider)),
credits = credits,
grants = { _, _ -> true },
usageRecords = usageRecords,
)
private fun request() = TextProviderRequest(
requestId = "request-123",
capability = GatewayCapability.AI,
input = "hello",
context = null,
maxOutputTokens = 32,
temperature = 0.2,
stream = false,
)
private class FakeCredits(
private val failSettle: Boolean = false,
private val idempotent: Boolean = false,
) : CreditMeterPort {
val settled = mutableListOf<Pair<String, Long>>()
val released = mutableListOf<String>()
val refunded = mutableListOf<String>()
var reserveCalls = 0
var lastEstimate: ProviderUsageEstimate? = null
override suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
): CreditReservation {
reserveCalls += 1
return CreditReservation(RESERVATION_ID, estimatedUnits)
}
override suspend fun reserve(
accountId: String,
estimate: ProviderUsageEstimate,
requestId: String,
): CreditReservation {
lastEstimate = estimate
return reserve(accountId, estimate.meter, estimate.units, requestId)
}
override suspend fun settle(reservationId: String, actualUnits: Long) {
val settlement = reservationId to actualUnits
if (!idempotent || settlement !in settled) settled += settlement
if (failSettle) throw BillingFailure()
}
override suspend fun settle(reservationId: String, usage: ProviderUsage) {
settle(reservationId, usage.units)
}
override suspend fun release(reservationId: String) {
if (!idempotent || reservationId !in released) released += reservationId
}
override suspend fun refund(reservationId: String) {
if (!idempotent || reservationId !in refunded) refunded += reservationId
}
}
private class FakeProvider(
private val fail: Boolean = false,
) : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "mock-deepseek",
capabilities = setOf(GatewayCapability.AI),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
if (fail) throw ProviderFailure()
return TOKEN_USAGE
}
}
private class EmptyResultProvider : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "empty-provider",
capabilities = setOf(GatewayCapability.AI),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
throw EmptyResultFailure()
}
}
private class FakeUsageRecords(
private val pending: MutableList<PendingSettlement> = mutableListOf(),
) : GatewayUsagePort {
override suspend fun claim(metadata: ProviderRequestMetadata) = Unit
override suspend fun markStarted(accountId: String, requestId: String) = Unit
override suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
) = Unit
override suspend fun markSucceeded(
accountId: String,
requestId: String,
usage: ProviderUsage,
) {
pending.removeAll { it.accountId == accountId && it.requestId == requestId }
}
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) = Unit
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) = Unit
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> =
pending.take(limit)
}
private val TOKEN_USAGE = ProviderUsage(
meter = UsageMeter.LLM_TOKEN,
units = 21,
inputUnits = 13,
outputUnits = 8,
)
private val PRINCIPAL = GatewayPrincipal(
userId = "account-1",
scopes = setOf(GatewayCapability.AI),
)
private val DISCARD_OUTPUT = ProviderOutput { }
private const val RESERVATION_ID = "reservation-1"
private class BillingFailure : RuntimeException()
private class ProviderFailure : RuntimeException()
private class EmptyResultFailure : RuntimeException()
@@ -0,0 +1,196 @@
package com.osglab.account.features.integrity
import com.osglab.account.config.AppleServiceEnvironment
import com.osglab.account.config.IntegrityConfig
import com.osglab.account.config.IntegrityPolicy
import com.upokecenter.cbor.CBORObject
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.math.BigInteger
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.Signature
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.util.Base64
class AppAttestCryptoTest : FunSpec({
test("attestation validates nonce RP ID AAGUID credential and counter") {
val fixture = AppAttestFixture()
val crypto = fixture.crypto()
val material = crypto.validateAttestation(
fixture.attestationObject(),
fixture.keyId,
fixture.challenge,
)
material.publicKey shouldBe fixture.keyPair.public.encoded
material.initialCounter shouldBe 0L
}
test("attestation rejects a nonce mismatch") {
val fixture = AppAttestFixture()
val crypto = fixture.crypto(nonce = ByteArray(32) { 9 })
shouldThrow<AppAttestRejectedException> {
crypto.validateAttestation(
fixture.attestationObject(),
fixture.keyId,
fixture.challenge,
)
}
}
test("attestation rejects an RP ID mismatch") {
val fixture = AppAttestFixture()
shouldThrow<AppAttestRejectedException> {
fixture.crypto().validateAttestation(
fixture.attestationObject(rpIdHash = ByteArray(32)),
fixture.keyId,
fixture.challenge,
)
}
}
test("attestation rejects an AAGUID environment mismatch") {
val fixture = AppAttestFixture()
shouldThrow<AppAttestRejectedException> {
fixture.crypto().validateAttestation(
fixture.attestationObject(
aaguid = "appattestdevelop".toByteArray(Charsets.US_ASCII),
),
fixture.keyId,
fixture.challenge,
)
}
}
test("assertion verifies ECDSA and requires a strictly increasing counter") {
val fixture = AppAttestFixture()
val hash = sha256ForTest("cost-request".toByteArray())
val assertion = fixture.assertionObject(counter = 4, clientDataHash = hash)
fixture.crypto().validateAssertion(
assertionObject = assertion,
clientDataHash = hash,
publicKey = fixture.keyPair.public.encoded,
lastCounter = 3,
) shouldBe 4L
shouldThrow<AppAttestRejectedException> {
fixture.crypto().validateAssertion(
assertionObject = assertion,
clientDataHash = hash,
publicKey = fixture.keyPair.public.encoded,
lastCounter = 4,
)
}
}
})
private class AppAttestFixture {
val keyPair: KeyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair()
val challenge: ByteArray = ByteArray(32) { it.toByte() }
val rpIdHash: ByteArray = sha256ForTest("X329MZU23S.com.osgkeyboard.ios".toByteArray())
val keyId: String = Base64.getEncoder().encodeToString(
sha256ForTest(uncompressedPointForTest(keyPair.public as ECPublicKey)),
)
fun crypto(nonce: ByteArray = expectedNonce()): LibraryAppAttestCrypto =
LibraryAppAttestCrypto(
IntegrityConfig(
deviceCheckPolicy = IntegrityPolicy.ENFORCE,
appAttestPolicy = IntegrityPolicy.ENFORCE,
appleEnvironment = AppleServiceEnvironment.PRODUCTION,
),
AppAttestCertificateValidator {
ValidatedAppAttestCertificate(keyPair.public as ECPublicKey, nonce)
},
)
fun attestationObject(
rpIdHash: ByteArray = this.rpIdHash,
aaguid: ByteArray = "appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7),
): ByteArray {
val authData = attestationAuthData(rpIdHash, aaguid)
return CBORObject.NewMap()
.Add("fmt", "apple-appattest")
.Add(
"attStmt",
CBORObject.NewMap()
.Add("x5c", CBORObject.NewArray().Add(byteArrayOf(1)))
.Add("receipt", byteArrayOf(2)),
)
.Add("authData", authData)
.EncodeToBytes()
}
fun assertionObject(counter: Int, clientDataHash: ByteArray): ByteArray {
val authData = ByteBuffer.allocate(37).order(ByteOrder.BIG_ENDIAN)
.put(rpIdHash)
.put(0)
.putInt(counter)
.array()
val signature = Signature.getInstance("SHA256withECDSA").run {
initSign(keyPair.private)
update(authData + clientDataHash)
sign()
}
return CBORObject.NewMap()
.Add("authenticatorData", authData)
.Add("signature", signature)
.EncodeToBytes()
}
private fun expectedNonce(): ByteArray =
sha256ForTest(attestationAuthData(rpIdHash, productionAaguid()) + sha256ForTest(challenge))
private fun productionAaguid(): ByteArray =
"appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7)
private fun attestationAuthData(rpHash: ByteArray, aaguid: ByteArray): ByteArray {
val publicKey = keyPair.public as ECPublicKey
val credentialId = Base64.getDecoder().decode(keyId)
val cose = CBORObject.NewMap()
.Add(1, 2)
.Add(3, -7)
.Add(-1, 1)
.Add(-2, publicKey.w.affineX.toFixedForTest(32))
.Add(-3, publicKey.w.affineY.toFixedForTest(32))
.EncodeToBytes()
return ByteBuffer.allocate(32 + 1 + 4 + 16 + 2 + credentialId.size + cose.size)
.order(ByteOrder.BIG_ENDIAN)
.put(rpHash)
.put(0x40)
.putInt(0)
.put(aaguid)
.putShort(credentialId.size.toShort())
.put(credentialId)
.put(cose)
.array()
}
}
private fun uncompressedPointForTest(key: ECPublicKey): ByteArray =
byteArrayOf(0x04) +
key.w.affineX.toFixedForTest(32) +
key.w.affineY.toFixedForTest(32)
private fun BigInteger.toFixedForTest(size: Int): ByteArray {
val bytes = toByteArray().let {
if (it.size == size + 1 && it.first() == 0.toByte()) it.copyOfRange(1, it.size) else it
}
return ByteArray(size - bytes.size) + bytes
}
private fun sha256ForTest(value: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(value)
@@ -0,0 +1,229 @@
package com.osglab.account.features.integrity
import com.osglab.account.config.IntegrityConfig
import com.osglab.account.config.IntegrityPolicy
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeTypeOf
import java.time.Clock
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Base64
import java.util.UUID
class AppAttestServiceTest : FunSpec({
val keyId = Base64.getEncoder().encodeToString(ByteArray(32) { 7 })
val payload = AppleSignInIntegrityPayload("identity", "authorization", "nonce")
val assertion = Base64.getEncoder().encodeToString(byteArrayOf(1))
test("expired challenge is rejected before assertion validation") {
val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z"))
val repository = InMemoryAppAttestRepository(keyId, counter = 0)
val crypto = FakeAppAttestCrypto(nextCounter = 1)
val service = appAttestService(repository, crypto, clock)
val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId)
clock.now = challenge.expiresAt.plusSeconds(1)
service.verify(
AppAttestEvidence(
keyId,
challenge.id.toString(),
assertion,
Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.value),
),
payload,
).shouldBeTypeOf<IntegrityVerification.Rejected>()
crypto.assertionCalls shouldBe 0
}
test("challenge cannot be replayed") {
val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z"))
val repository = InMemoryAppAttestRepository(keyId, counter = 0)
val crypto = FakeAppAttestCrypto(nextCounter = 1)
val service = appAttestService(repository, crypto, clock)
val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId)
val evidence = AppAttestEvidence(
keyId,
challenge.id.toString(),
assertion,
Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.value),
)
service.verify(evidence, payload) shouldBe IntegrityVerification.Verified
service.verify(evidence, payload).shouldBeTypeOf<IntegrityVerification.Rejected>()
crypto.assertionCalls shouldBe 1
}
test("counter rollback is rejected even if a crypto adapter returns it") {
val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z"))
val repository = InMemoryAppAttestRepository(keyId, counter = 8)
val crypto = FakeAppAttestCrypto(nextCounter = 8)
val service = appAttestService(repository, crypto, clock)
val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId)
service.verify(
AppAttestEvidence(
keyId,
challenge.id.toString(),
assertion,
Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.value),
),
payload,
).shouldBeTypeOf<IntegrityVerification.Rejected>()
repository.keys.getValue(keyId).counter shouldBe 8
}
test("server rebuilds canonical client data from the challenge and login fields") {
val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z"))
val repository = InMemoryAppAttestRepository(keyId, counter = 2)
val crypto = FakeAppAttestCrypto(nextCounter = 3)
val service = appAttestService(repository, crypto, clock)
val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId)
service.verify(
AppAttestEvidence(
keyId,
challenge.id.toString(),
assertion,
Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.value),
),
payload,
) shouldBe IntegrityVerification.Verified
crypto.clientDataHash shouldBe java.security.MessageDigest.getInstance("SHA-256")
.digest(AppAttestCanonicalPayload.appleSignIn(challenge.value, payload))
}
test("bound assertion rejects a key owned by another account") {
val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z"))
val ownerId = UUID.randomUUID()
val requesterId = UUID.randomUUID()
val repository = InMemoryAppAttestRepository(keyId, counter = 0, accountId = ownerId)
val crypto = FakeAppAttestCrypto(nextCounter = 1)
val service = appAttestService(repository, crypto, clock)
val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId)
shouldThrow<AppAttestRejectedException> {
service.verifyBoundAssertion(
challengeId = challenge.id.toString(),
challenge = challenge.value,
keyId = keyId,
assertionObject = assertion,
expectedClientDataHash = ByteArray(32),
expectedAccountId = requesterId,
)
}
crypto.assertionCalls shouldBe 0
}
})
private fun appAttestService(
repository: AppAttestRepository,
crypto: AppAttestCrypto,
clock: Clock,
) = AppAttestService(
repository = repository,
crypto = crypto,
config = IntegrityConfig(
deviceCheckPolicy = IntegrityPolicy.MONITOR,
appAttestPolicy = IntegrityPolicy.ENFORCE,
challengeLifetimeSeconds = 300,
),
clock = clock,
)
private class FakeAppAttestCrypto(
private val nextCounter: Long,
) : AppAttestCrypto {
var assertionCalls = 0
var clientDataHash: ByteArray? = null
override suspend fun validateAttestation(
attestationObject: ByteArray,
keyId: String,
challenge: ByteArray,
) = AttestedKeyMaterial(byteArrayOf(1), byteArrayOf(2), 0)
override suspend fun validateAssertion(
assertionObject: ByteArray,
clientDataHash: ByteArray,
publicKey: ByteArray,
lastCounter: Long,
): Long {
assertionCalls++
this.clientDataHash = clientDataHash
return nextCounter
}
}
private class InMemoryAppAttestRepository(
keyId: String,
counter: Long,
accountId: UUID? = null,
) : AppAttestRepository {
private val challenges = mutableMapOf<UUID, AppAttestChallenge>()
val keys = mutableMapOf(
keyId to StoredAppAttestKey(keyId, byteArrayOf(1), byteArrayOf(2), counter, accountId),
)
override suspend fun createChallenge(challenge: AppAttestChallenge) {
challenges[challenge.id] = challenge
}
override suspend fun consumeChallenge(
id: UUID,
purpose: AppAttestChallengePurpose,
keyId: String,
challengeHash: String,
accountId: UUID?,
now: Instant,
): ConsumedChallenge {
val challenge = challenges[id] ?: return ConsumedChallenge.MissingOrMismatched
if (challenge.purpose != purpose || challenge.keyId != keyId) {
return ConsumedChallenge.MissingOrMismatched
}
if (challenge.challengeHash != challengeHash) {
return ConsumedChallenge.MissingOrMismatched
}
if (challenge.status == AppAttestChallengeStatus.CONSUMED) return ConsumedChallenge.Replayed
if (!challenge.expiresAt.isAfter(now)) return ConsumedChallenge.Expired
challenges[id] = challenge.copy(
status = AppAttestChallengeStatus.CONSUMED,
consumedAt = now,
)
return ConsumedChallenge.Valid
}
override suspend fun saveKey(key: StoredAppAttestKey): Boolean =
keys.putIfAbsent(key.keyId, key) == null
override suspend fun findKey(keyId: String): StoredAppAttestKey? = keys[keyId]
override suspend fun updateCounter(
keyId: String,
expectedCounter: Long,
newCounter: Long,
now: Instant,
): Boolean {
val key = keys[keyId] ?: return false
if (key.counter != expectedCounter || newCounter <= expectedCounter) return false
keys[keyId] = key.copy(counter = newCounter)
return true
}
override suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant): Boolean {
val key = keys[keyId] ?: return false
if (key.accountId != null && key.accountId != accountId) return false
keys[keyId] = key.copy(accountId = accountId)
return true
}
}
private class MutableClock(
var now: Instant,
) : Clock() {
override fun instant(): Instant = now
override fun getZone(): ZoneId = ZoneOffset.UTC
override fun withZone(zone: ZoneId): Clock = this
}
@@ -0,0 +1,14 @@
package com.osglab.account.features.integrity
import io.kotest.matchers.shouldBe
import kotlin.test.Test
class BundledAppleAppAttestTrustTest {
@Test
fun `bundled root is the official Apple App Attestation CA`() {
val certificate = BundledAppleAppAttestTrust.loadRootCertificate()
certificate.subjectX500Principal.name.contains("Apple App Attestation Root CA") shouldBe true
(certificate.basicConstraints >= 0) shouldBe true
}
}
@@ -0,0 +1,350 @@
package com.osglab.account.features.integrity
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.crypto.ECDSAVerifier
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.config.AppleServiceEnvironment
import com.osglab.account.config.IntegrityPolicy
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.HttpTimeout
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.Base64
import java.util.UUID
class DeviceCheckTest : FunSpec({
test("ES256 JWT contains Apple team and key identifiers") {
val keyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair()
val pem = """
-----BEGIN PRIVATE KEY-----
${Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(keyPair.private.encoded)}
-----END PRIVATE KEY-----
""".trimIndent()
val now = Instant.parse("2026-08-16T00:00:00Z")
val encoded = DeviceCheckJwtGenerator(
teamId = "X329MZU23S",
keyId = "APPLEKEY1",
privateKeyPem = pem,
clock = Clock.fixed(now, ZoneOffset.UTC),
).create()
val jwt = SignedJWT.parse(encoded)
jwt.header.algorithm shouldBe JWSAlgorithm.ES256
jwt.header.keyID shouldBe "APPLEKEY1"
jwt.jwtClaimsSet.issuer shouldBe "X329MZU23S"
jwt.jwtClaimsSet.issueTime.toInstant() shouldBe now
jwt.jwtClaimsSet.expirationTime.toInstant() shouldBe now.plusSeconds(55 * 60)
jwt.verify(ECDSAVerifier(keyPair.public as ECPublicKey)).shouldBeTrue()
}
test("an Apple bit0 claim never grants signup credits") {
val repository = InMemoryTrialRepository()
var updates = 0
var grants = 0
val service = DeviceCheckTrialService(
repository = repository,
client = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String) =
DeviceCheckQuery.Found(DeviceCheckState(true, false, "2026-08"))
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) {
updates++
}
},
creditGranter = TrialCreditGranter { grants++ },
policy = IntegrityPolicy.ENFORCE,
)
service.claimAndGrant(UUID.randomUUID(), Base64.getEncoder().encodeToString(ByteArray(32))).shouldBeFalse()
updates shouldBe 0
grants shouldBe 0
repository.claims.values.single().status shouldBe TrialClaimStatus.REJECTED
}
test("Apple is marked before the idempotent credit boundary") {
val events = mutableListOf<String>()
val repository = InMemoryTrialRepository(events)
var queryCount = 0
val service = DeviceCheckTrialService(
repository = repository,
client = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
if (queryCount++ == 0) {
DeviceCheckQuery.NotFound
} else {
DeviceCheckQuery.Found(DeviceCheckState(true, false, "2026-08"))
}
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) {
bit0.shouldBeTrue()
events += "apple"
}
},
creditGranter = TrialCreditGranter { events += "credits" },
policy = IntegrityPolicy.ENFORCE,
)
service.claimAndGrant(UUID.randomUUID(), Base64.getEncoder().encodeToString(ByteArray(32) { 1 }))
.shouldBeTrue()
(events.indexOf("apple") < events.indexOf("credits")) shouldBe true
events shouldBe listOf("apple", "APPLE_MARKED", "credits", "COMPLETED")
}
test("monitor skips a trial while enforce fails closed on Apple outage") {
val unavailable = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
throw DeviceCheckUnavailableException("network")
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean): Unit =
error("not reached")
}
val token = Base64.getEncoder().encodeToString(ByteArray(32) { 2 })
val accountId = UUID.randomUUID()
DeviceCheckTrialService(
InMemoryTrialRepository(),
unavailable,
TrialCreditGranter { error("must not grant") },
IntegrityPolicy.MONITOR,
).claimAndGrant(accountId, token).shouldBeFalse()
shouldThrow<ExternalServiceUnavailableException> {
DeviceCheckTrialService(
InMemoryTrialRepository(),
unavailable,
TrialCreditGranter { error("must not grant") },
IntegrityPolicy.ENFORCE,
).claimAndGrant(accountId, token)
}
}
test("different ephemeral tokens for one device are serialized across the claim window") {
val repository = InMemoryTrialRepository()
val lock = Mutex()
var appleBit = false
var grants = 0
val service = DeviceCheckTrialService(
repository = repository,
client = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
DeviceCheckQuery.Found(DeviceCheckState(appleBit, false, null))
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) {
appleBit = bit0
}
},
creditGranter = TrialCreditGranter { grants++ },
policy = IntegrityPolicy.ENFORCE,
mutex = object : DeviceCheckTrialMutex {
override suspend fun <T> withLock(block: suspend () -> T): T =
lock.withLock { block() }
},
)
val results = coroutineScope {
listOf(3, 4).map { marker ->
async {
service.claimAndGrant(
UUID.randomUUID(),
Base64.getEncoder().encodeToString(ByteArray(32) { marker.toByte() }),
)
}
}.awaitAll()
}
results.count { it } shouldBe 1
grants shouldBe 1
}
test("an unconfirmed Apple mark never grants credits") {
var grants = 0
val service = DeviceCheckTrialService(
repository = InMemoryTrialRepository(),
client = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String) = DeviceCheckQuery.NotFound
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) = Unit
},
creditGranter = TrialCreditGranter { grants++ },
policy = IntegrityPolicy.ENFORCE,
)
shouldThrow<ExternalServiceUnavailableException> {
service.claimAndGrant(
UUID.randomUUID(),
Base64.getEncoder().encodeToString(ByteArray(32) { 5 }),
)
}
grants shouldBe 0
}
test("DeviceCheck maps rejected and temporary Apple errors differently") {
suspend fun queryFor(status: HttpStatusCode): Throwable {
val client = HttpClient(
MockEngine {
respond(
content = """{"error":"redacted"}""",
status = status,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
},
) {
install(HttpTimeout)
}
return try {
runCatching {
KtorAppleDeviceCheckClient(
client,
testJwtGenerator(),
AppleServiceEnvironment.PRODUCTION,
).query(Base64.getEncoder().encodeToString(ByteArray(32)))
}.exceptionOrNull()!!
} finally {
client.close()
}
}
queryFor(HttpStatusCode.BadRequest)::class shouldBe DeviceCheckRejectedException::class
queryFor(HttpStatusCode.Unauthorized)::class shouldBe DeviceCheckUnavailableException::class
queryFor(HttpStatusCode.TooManyRequests)::class shouldBe DeviceCheckUnavailableException::class
queryFor(HttpStatusCode.ServiceUnavailable)::class shouldBe DeviceCheckUnavailableException::class
}
test("DeviceCheck uses environment-specific Apple hosts") {
suspend fun hostFor(environment: AppleServiceEnvironment): String {
var host = ""
val client = HttpClient(
MockEngine { request ->
host = request.url.host
respond(
content = """{"bit0":false,"bit1":false}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
},
) {
install(HttpTimeout)
}
try {
KtorAppleDeviceCheckClient(client, testJwtGenerator(), environment)
.query(Base64.getEncoder().encodeToString(ByteArray(32)))
} finally {
client.close()
}
return host
}
hostFor(AppleServiceEnvironment.DEVELOPMENT) shouldBe
"api.development.devicecheck.apple.com"
hostFor(AppleServiceEnvironment.PRODUCTION) shouldBe
"api.devicecheck.apple.com"
}
test("DeviceCheck maps Apple's successful missing-state response to NotFound") {
val client = HttpClient(
MockEngine {
respond(
content = "Failed to find bit state\n",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "text/plain"),
)
},
) {
install(HttpTimeout)
}
try {
KtorAppleDeviceCheckClient(
client,
testJwtGenerator(),
AppleServiceEnvironment.PRODUCTION,
).query(Base64.getEncoder().encodeToString(ByteArray(32))) shouldBe
DeviceCheckQuery.NotFound
} finally {
client.close()
}
}
test("DeviceCheck maps request timeout to temporary unavailability") {
val client = HttpClient(
MockEngine {
delay(250)
respond(
content = """{"bit0":false,"bit1":false}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
},
) {
install(HttpTimeout)
}
try {
shouldThrow<DeviceCheckUnavailableException> {
KtorAppleDeviceCheckClient(
client,
testJwtGenerator(),
AppleServiceEnvironment.PRODUCTION,
timeoutMillis = 20,
).query(Base64.getEncoder().encodeToString(ByteArray(32)))
}
} finally {
client.close()
}
}
})
private fun testJwtGenerator(): DeviceCheckJwtGenerator {
val keyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair()
val pem = """
-----BEGIN PRIVATE KEY-----
${Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(keyPair.private.encoded)}
-----END PRIVATE KEY-----
""".trimIndent()
return DeviceCheckJwtGenerator("TEAM", "KEY", pem)
}
private class InMemoryTrialRepository(
private val events: MutableList<String>? = null,
) : DeviceCheckTrialClaimRepository {
val claims = mutableMapOf<String, TrialClaim>()
override suspend fun begin(tokenHash: String, accountId: UUID, now: Instant): BeginTrialClaim {
val existing = claims[tokenHash]
if (existing != null && existing.accountId != accountId) {
return BeginTrialClaim.ClaimedByAnotherAccount
}
val claim = existing ?: TrialClaim(tokenHash, accountId, TrialClaimStatus.RESERVED)
claims[tokenHash] = claim
return BeginTrialClaim.Owned(claim)
}
override suspend fun transition(tokenHash: String, status: TrialClaimStatus, now: Instant) {
claims[tokenHash] = requireNotNull(claims[tokenHash]).copy(status = status)
events?.add(status.name)
}
}
@@ -0,0 +1,101 @@
package com.osglab.account.features.integrity
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.util.UUID
class IntegrityPortsTest : FunSpec({
test("unsupported promotional evidence is explicitly ineligible") {
val port = DefaultIntegrityRiskPort(
deviceCheckClient = unavailableDeviceCheck(),
appAttestRepository = EmptyAppAttestRepository,
)
port.assess(
IntegrityRiskRequest(UUID.randomUUID(), IntegrityRiskUseCase.SIGNUP_TRIAL),
) shouldBe IntegrityRiskDecision(
IntegrityEligibility.INELIGIBLE,
IntegrityEvidenceState.UNSUPPORTED,
)
}
test("temporary DeviceCheck failure asks promotional caller to retry") {
val port = DefaultIntegrityRiskPort(
deviceCheckClient = unavailableDeviceCheck(),
appAttestRepository = EmptyAppAttestRepository,
)
port.assess(
IntegrityRiskRequest(
UUID.randomUUID(),
IntegrityRiskUseCase.SIGNUP_TRIAL,
deviceCheckToken = "token",
),
) shouldBe IntegrityRiskDecision(
IntegrityEligibility.RETRY_LATER,
IntegrityEvidenceState.TEMPORARILY_UNAVAILABLE,
)
}
test("trial and risk bits both deny another signup trial") {
suspend fun decision(bit0: Boolean, bit1: Boolean): IntegrityRiskDecision {
val port = DefaultIntegrityRiskPort(
deviceCheckClient = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String) =
DeviceCheckQuery.Found(DeviceCheckState(bit0, bit1, null))
override suspend fun update(
deviceToken: String,
bit0: Boolean,
bit1: Boolean,
) = Unit
},
appAttestRepository = EmptyAppAttestRepository,
)
return port.assess(
IntegrityRiskRequest(
UUID.randomUUID(),
IntegrityRiskUseCase.SIGNUP_TRIAL,
deviceCheckToken = "token",
),
)
}
decision(bit0 = true, bit1 = false).eligibility shouldBe IntegrityEligibility.INELIGIBLE
decision(bit0 = false, bit1 = true).eligibility shouldBe IntegrityEligibility.INELIGIBLE
}
})
private fun unavailableDeviceCheck() = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
throw DeviceCheckUnavailableException("timeout")
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean): Unit =
throw DeviceCheckUnavailableException("timeout")
}
private object EmptyAppAttestRepository : AppAttestRepository {
override suspend fun createChallenge(challenge: AppAttestChallenge) = Unit
override suspend fun consumeChallenge(
id: UUID,
purpose: AppAttestChallengePurpose,
keyId: String,
challengeHash: String,
accountId: UUID?,
now: Instant,
) = ConsumedChallenge.MissingOrMismatched
override suspend fun saveKey(key: StoredAppAttestKey) = false
override suspend fun findKey(keyId: String): StoredAppAttestKey? = null
override suspend fun updateCounter(
keyId: String,
expectedCounter: Long,
newCounter: Long,
now: Instant,
) = false
override suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant) = false
}
@@ -0,0 +1,60 @@
package com.osglab.account.features.integrity
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.config.IntegrityConfig
import com.osglab.account.config.IntegrityPolicy
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
class IntegrityServiceTest : FunSpec({
test("monitor policy fails open when Apple verification is unavailable") {
val service = service(
IntegrityPolicy.MONITOR,
IntegrityVerification.Unavailable("not configured"),
)
service.verifyAppleSignIn(IntegrityEvidence(), payload())
}
test("enforce policy fails closed when verification is unavailable") {
val service = service(
IntegrityPolicy.ENFORCE,
IntegrityVerification.Unavailable("network error"),
)
shouldThrow<ExternalServiceUnavailableException> {
service.verifyAppleSignIn(
IntegrityEvidence(deviceCheckToken = "token"),
payload(),
)
}
}
test("cryptographically rejected evidence is never fail open") {
val service = service(
IntegrityPolicy.MONITOR,
IntegrityVerification.Rejected("invalid"),
)
shouldThrow<InvalidRequestException> {
service.verifyAppleSignIn(
IntegrityEvidence(deviceCheckToken = "token"),
payload(),
)
}
}
})
private fun service(
devicePolicy: IntegrityPolicy,
deviceResult: IntegrityVerification,
): IntegrityService = IntegrityService(
config = IntegrityConfig(devicePolicy, IntegrityPolicy.MONITOR),
deviceCheckVerifier = object : DeviceCheckVerifier {
override suspend fun verify(deviceToken: String): IntegrityVerification = deviceResult
},
appAttestVerifier = UnavailableAppAttestVerifier(),
)
private fun payload() = AppleSignInIntegrityPayload("identity", "code", "nonce")
@@ -0,0 +1,211 @@
package com.osglab.account.features.inviteweb
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.delay
import kotlin.test.Test
class InviteWebRoutesTest {
private val config = InviteWebConfig(
appStoreUrl = "https://apps.apple.com/app/id1234567890",
universalLinkBaseUrl = "https://osglab.com/i",
appleAppId = "ABCDE12345.com.example.osg",
)
@Test
fun `valid referral renders a bilingual first-party page with hardened headers`() = testApplication {
application {
routing {
configureInviteWebRoutes(ReferralLookupPort { true }, config)
}
}
val response = client.get("/i/$VALID_CODE")
val body = response.bodyAsText()
response.status shouldBe HttpStatusCode.OK
response.headers[HttpHeaders.ContentType].orEmpty() shouldContain "text/html"
response.headers[HttpHeaders.CacheControl] shouldBe "no-store, max-age=0"
response.headers["Referrer-Policy"] shouldBe "no-referrer"
response.headers["X-Frame-Options"] shouldBe "DENY"
response.headers["X-Robots-Tag"] shouldBe "noindex, nofollow, noarchive"
response.headers["Content-Security-Policy"].orEmpty() shouldContain "script-src 'nonce-"
body shouldContain VALID_CODE
body shouldContain "复制邀请码"
body shouldContain "Copy invitation code"
body shouldContain "href=\"https://osglab.com/i/$VALID_CODE\""
body shouldContain "https://apps.apple.com/app/id1234567890"
body shouldNotContain "analytics"
body shouldNotContain "googletag"
body shouldNotContain "firebase"
body shouldNotContain "branch.io"
body shouldNotContain "appsflyer"
body shouldNotContain "adjust.com"
}
@Test
fun `malformed referral is rejected without invoking lookup`() = testApplication {
val lookedUpCodes = mutableListOf<String>()
application {
routing {
configureInviteWebRoutes(
referralLookup = ReferralLookupPort {
lookedUpCodes += it
true
},
config = config,
)
}
}
val response = client.get("/i/not-valid")
response.status shouldBe HttpStatusCode.NotFound
response.headers[HttpHeaders.CacheControl] shouldBe "no-store, max-age=0"
lookedUpCodes shouldBe emptyList()
}
@Test
fun `unknown referral returns the same generic not found response`() = testApplication {
application {
routing {
configureInviteWebRoutes(ReferralLookupPort { false }, config)
}
}
val response = client.get("/i/$VALID_CODE")
response.status shouldBe HttpStatusCode.NotFound
response.bodyAsText() shouldBe
"邀请链接无效或已失效 / This invitation link is invalid or expired"
}
@Test
fun `lookup failure fails closed without exposing the exception`() = testApplication {
application {
routing {
configureInviteWebRoutes(
ReferralLookupPort { error("database password must not escape") },
config,
)
}
}
val response = client.get("/i/$VALID_CODE")
val body = response.bodyAsText()
response.status shouldBe HttpStatusCode.ServiceUnavailable
response.headers[HttpHeaders.RetryAfter] shouldBe "30"
body shouldNotContain "password"
body shouldContain "temporarily unavailable"
}
@Test
fun `lookup timeout fails closed with retry guidance`() = testApplication {
application {
routing {
configureInviteWebRoutes(
referralLookup = ReferralLookupPort {
delay(250)
true
},
config = config.copy(lookupTimeoutMillis = 100),
)
}
}
val response = client.get("/i/$VALID_CODE")
response.status shouldBe HttpStatusCode.ServiceUnavailable
response.headers[HttpHeaders.RetryAfter] shouldBe "30"
}
@Test
fun `both AASA paths return the same no-store JSON document`() = testApplication {
application {
routing {
configureInviteWebRoutes(ReferralLookupPort { true }, config)
}
}
val wellKnown = client.get("/.well-known/apple-app-site-association")
val root = client.get("/apple-app-site-association")
wellKnown.status shouldBe HttpStatusCode.OK
root.status shouldBe HttpStatusCode.OK
wellKnown.headers[HttpHeaders.ContentType].orEmpty() shouldContain "application/json"
wellKnown.headers[HttpHeaders.CacheControl] shouldBe "no-store, max-age=0"
wellKnown.bodyAsText() shouldBe root.bodyAsText()
wellKnown.bodyAsText() shouldContain "\"ABCDE12345.com.example.osg\""
wellKnown.bodyAsText() shouldContain "\"/i/*\""
}
@Test
fun `configuration rejects unsafe URLs and malformed app IDs`() {
shouldThrow<IllegalArgumentException> {
InviteWebConfig(
appStoreUrl = "http://apps.apple.com/app/id123",
appleAppId = "ABCDE12345.com.example.osg",
)
}
shouldThrow<IllegalArgumentException> {
InviteWebConfig(
appStoreUrl = "https://apps.apple.com/app/id123",
appleAppId = "ABCDE12345.com.example.osg",
universalLinkBaseUrl = "https://user@osglab.com/i",
)
}
shouldThrow<IllegalArgumentException> {
InviteWebConfig(
appStoreUrl = "https://example.com/fake-store",
appleAppId = "ABCDE12345.com.example.osg",
)
}
shouldThrow<IllegalArgumentException> {
InviteWebConfig(
appStoreUrl = "https://apps.apple.com/app/id123",
appleAppId = "ABCDE12345.com.example.osg",
universalLinkBaseUrl = "https://example.com/i",
)
}
shouldThrow<IllegalArgumentException> {
InviteWebConfig(
appStoreUrl = "https://apps.apple.com/app/id123",
appleAppId = "invalid",
)
}
}
@Test
fun `rendering safely encodes configured links and rejects path injection`() = testApplication {
val configWithQuery = config.copy(
appStoreUrl = "https://apps.apple.com/app/id1234567890?pt=1&ct=invite",
)
application {
routing {
configureInviteWebRoutes(ReferralLookupPort { true }, configWithQuery)
}
}
val response = client.get("/i/$VALID_CODE")
val body = response.bodyAsText()
response.status shouldBe HttpStatusCode.OK
body shouldContain
"href=\"https://apps.apple.com/app/id1234567890?pt=1&amp;ct=invite\""
body shouldNotContain "?pt=1&ct=invite"
client.get("/i/${VALID_CODE}%2Ftracking").status shouldBe HttpStatusCode.NotFound
}
private companion object {
const val VALID_CODE = "AbCdEf0123456789_-AbCd"
}
}
@@ -0,0 +1,206 @@
package com.osglab.account.features.referrals
import com.osglab.account.features.credits.TestBillingStore
import com.osglab.account.features.referrals.domain.InviteCodeGenerator
import com.osglab.account.features.referrals.domain.ReferralBindingRules
import com.osglab.account.features.referrals.domain.ReferralConflict
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.domain.ReferralWindowExpired
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 io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
class ReferralServiceTest : FunSpec({
val now = Instant.parse("2026-08-15T00:00:00Z")
test("code creation is stable and code has non-enumerable length") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val first = service.getOrCreateCode(owner)
val second = service.getOrCreateCode(owner)
second shouldBe first
first.code.length shouldBe 22
store.codes.size shouldBe 1
}
test("an account binds once and repeated same binding is idempotent") {
val store = TestBillingStore()
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
var identityLookups = 0
var registrationLookups = 0
val service = referralService(
store = store,
now = now,
riskIdentity = { userId ->
identityLookups += 1
ReferralRiskIdentity(fingerprint(userId), restricted = false)
},
registeredAt = {
registrationLookups += 1
now.minus(Duration.ofDays(1))
},
)
val code = service.getOrCreateCode(inviter)
val first = service.bind(invitee, code.code)
val identityLookupsAfterFirstBind = identityLookups
val registrationLookupsAfterFirstBind = registrationLookups
val second = service.bind(invitee, code.code)
second shouldBe first
store.bindings.size shouldBe 1
identityLookups shouldBe identityLookupsAfterFirstBind
registrationLookups shouldBe registrationLookupsAfterFirstBind
}
test("self-referral and binding after the configured window are rejected") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
val inWindow = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val code = inWindow.getOrCreateCode(owner)
shouldThrow<ReferralConflict> {
inWindow.bind(owner, code.code)
}
val expired = referralService(store, now) { now.minus(Duration.ofDays(8)) }
shouldThrow<ReferralWindowExpired> {
expired.bind(UUID.randomUUID(), code.code)
}
}
test("identity tombstones prevent self-referral after account recreation") {
val store = TestBillingStore()
val deletedAccount = UUID.randomUUID()
val recreatedAccount = UUID.randomUUID()
val sharedFingerprint = "f".repeat(64)
val service = referralService(
store = store,
now = now,
registeredAt = { now.minus(Duration.ofDays(1)) },
riskIdentity = {
ReferralRiskIdentity(sharedFingerprint, restricted = false)
},
)
val code = service.getOrCreateCode(deletedAccount)
shouldThrow<ReferralConflict> {
service.bind(recreatedAccount, code.code)
}
}
test("campaign binding window overrides the legacy default window") {
val store = TestBillingStore()
val campaignId = UUID.randomUUID()
store.campaigns[campaignId] = ReferralCampaign(
id = campaignId,
name = "Short campaign",
startsAt = now.minusSeconds(60),
endsAt = now.plusSeconds(3_600),
bindingWindowSeconds = 3_600,
inviterRewardCredits = 10,
inviteeRewardCredits = 10,
maxRewardedBindings = 10,
budgetCredits = 200,
enabled = true,
)
store.campaignBudgets[campaignId] = ReferralCampaignBudget(campaignId, 0, 0, now)
val service = referralService(store, now) { now.minus(Duration.ofHours(2)) }
val code = service.getOrCreateCode(UUID.randomUUID(), campaignId)
shouldThrow<ReferralWindowExpired> {
service.bind(UUID.randomUUID(), code.code)
}
}
test("campaign budget must cover one bilateral reward") {
shouldThrow<IllegalArgumentException> {
ReferralCampaign(
id = UUID.randomUUID(),
name = "Underfunded",
startsAt = now.minusSeconds(1),
endsAt = null,
bindingWindowSeconds = 3_600,
inviterRewardCredits = 10,
inviteeRewardCredits = 10,
maxRewardedBindings = null,
budgetCredits = 19,
enabled = true,
)
}
}
test("binding rules include the exact deadline and detect recreated identities") {
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
val registeredAt = now.minus(Duration.ofDays(7))
val fingerprint = "a".repeat(64)
val code = ReferralCode(
id = UUID.randomUUID(),
ownerUserId = inviter,
ownerIdentityFingerprint = fingerprint,
code = "abcdefghijklmnopqrstuv",
createdAt = registeredAt,
)
ReferralBindingRules.isWithinWindow(
registeredAt,
now,
Duration.ofDays(7),
) shouldBe true
ReferralBindingRules.isWithinWindow(
registeredAt,
now.plusNanos(1),
Duration.ofDays(7),
) shouldBe false
ReferralBindingRules.isSelfReferral(invitee, fingerprint, code) shouldBe true
ReferralBindingRules.isSelfReferral(invitee, "b".repeat(64), code) shouldBe false
ReferralBindingRules.isWithinWindow(
registeredAt = now,
attemptedAt = now.minusNanos(1),
bindingWindow = Duration.ofDays(7),
) shouldBe false
}
})
private fun referralService(
store: TestBillingStore,
now: Instant,
riskIdentity: (UUID) -> ReferralRiskIdentity = { userId ->
ReferralRiskIdentity(fingerprint(userId), restricted = false)
},
registeredAt: (UUID) -> Instant,
): ReferralService {
val sequence = AtomicInteger()
val generator = InviteCodeGenerator {
val suffix = sequence.incrementAndGet().toString().padStart(2, '0')
"abcdefghijklmnopqrst$suffix"
}
return ReferralService(
transactions = store,
registrationTimeProvider = UserRegistrationTimeProvider(registeredAt),
riskProvider = ReferralRiskProvider(riskIdentity),
bindingWindow = Duration.ofDays(7),
codeGenerator = generator,
clock = Clock.fixed(now, ZoneOffset.UTC),
)
}
private fun fingerprint(userId: UUID): String =
userId.toString().replace("-", "").repeat(2)
@@ -0,0 +1,340 @@
package com.osglab.account.integration
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.common.security.SessionJwt
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.config.SessionConfig
import com.osglab.account.features.account.ExposedAccountRepository
import com.osglab.account.features.auth.ExposedAuthRepository
import com.osglab.account.features.auth.SessionAccessAuthenticator
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.UsageMeasurement
import com.osglab.account.features.credits.repositories.ExposedBillingTransactionRunner
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.referrals.services.ReferralRiskIdentity
import com.osglab.account.features.referrals.services.ReferralRiskProvider
import com.osglab.account.features.referrals.services.ReferralService
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.assertions.withClue
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.ints.shouldBeExactly
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.sql.Connection
import java.sql.DriverManager
import java.time.Duration
import java.time.Instant
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
class MySqlSecurityIntegrationTest : FunSpec({
test("migrations enforce deletion, session, ledger concurrency and referral idempotency") {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
KotlinMySqlContainer("mysql:8.4")
.withDatabaseName("osg_security_test")
.withUsername("test")
.withPassword("test")
.also(KotlinMySqlContainer::start)
} else {
null
}
val jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl
val username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root"
val password = System.getenv("TEST_MYSQL_PASSWORD")
?: mysql?.password
?: ""
fun connection(): Connection = DriverManager.getConnection(jdbcUrl, username, password)
val databaseConfig = DatabaseConfig(
jdbcUrl = jdbcUrl,
username = username,
password = password,
maximumPoolSize = 8,
)
val factory = DatabaseFactory(databaseConfig)
try {
factory.database
val secondFactory = DatabaseFactory(databaseConfig)
try {
val inside = AtomicInteger()
val maximumInside = AtomicInteger()
coroutineScope {
listOf(factory, secondFactory).map { lockOwner ->
async(Dispatchers.Default) {
lockOwner.withMysqlNamedLock("integration-global-trial", 10) {
val current = inside.incrementAndGet()
maximumInside.updateAndGet { previous -> maxOf(previous, current) }
delay(100)
inside.decrementAndGet()
}
}
}.awaitAll()
}
maximumInside.get() shouldBeExactly 1
} finally {
secondFactory.close()
}
val identity = IdentityFingerprint(ByteArray(32) { 7 })
val authRepository = ExposedAuthRepository(factory)
val accountRepository = ExposedAccountRepository(factory, identity)
val sessionConfig = SessionConfig(
issuer = "https://issuer.example",
audience = "ios",
hmacSecret = ByteArray(32) { 4 },
accessMinutes = 15,
refreshDays = 30,
)
val sessionJwt = SessionJwt(sessionConfig)
val authenticator = SessionAccessAuthenticator(sessionJwt, authRepository)
val deletedUser = UUID.randomUUID()
val deletedFamily = UUID.randomUUID()
val deletedSession = UUID.randomUUID()
val deletedFingerprint = identity.ofAppleSubject("deleted-apple-sub")
connection().use { connection ->
insertAccount(connection, deletedUser, "deleted-apple-sub", deletedFingerprint)
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
INSERT INTO sessions (
id, account_id, family_id, refresh_token_hash, created_at, expires_at
) VALUES (
'$deletedSession', '$deletedUser', '$deletedFamily',
'${"1".repeat(64)}', CURRENT_TIMESTAMP(6),
DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL 1 DAY)
)
""".trimIndent(),
)
statement.executeUpdate(
"INSERT INTO credit_accounts (user_id, balance) VALUES ('$deletedUser', 10)",
)
statement.executeUpdate(
"""
INSERT INTO credit_ledger (
id, user_id, entry_type, amount_delta, balance_after, idempotency_key
) VALUES (
'${UUID.randomUUID()}', '$deletedUser', 'MANUAL_GRANT', 10, 10,
'integration-ledger-retained'
)
""".trimIndent(),
)
statement.executeUpdate(
"""
INSERT INTO referral_codes (
id, owner_user_id, owner_identity_fingerprint, code
) VALUES (
'${UUID.randomUUID()}', '$deletedUser', '$deletedFingerprint',
'abcdefghijklmnopqrstuv'
)
""".trimIndent(),
)
statement.executeUpdate(
"""
INSERT INTO gateway_grants (
id, account_id, idempotency_key, expires_at, created_at, updated_at
) VALUES (
'${UUID.randomUUID()}', '$deletedUser', 'integration-delete-grant',
DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL 1 DAY),
CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6)
)
""".trimIndent(),
)
}
}
val accessToken = sessionJwt.issue(deletedUser, deletedSession).value
authenticator.authenticate(accessToken).shouldNotBeNull()
val deletedAt = Instant.now()
accountRepository.deleteById(
deletedUser,
deletedAt,
deletedAt.plus(Duration.ofDays(365)),
createRevocation = { null },
)
authenticator.authenticate(accessToken).shouldBeNull()
connection().use { connection ->
count(connection, "accounts", "id = '$deletedUser'") shouldBeExactly 0
count(connection, "sessions", "account_id = '$deletedUser'") shouldBeExactly 0
count(connection, "credit_accounts", "user_id = '$deletedUser'") shouldBeExactly 0
count(connection, "referral_codes", "owner_user_id = '$deletedUser'") shouldBeExactly 0
count(connection, "gateway_grants", "account_id = '$deletedUser'") shouldBeExactly 0
count(connection, "credit_ledger", "user_id = '$deletedUser'") shouldBeExactly 1
count(
connection,
"account_identity_tombstones",
"identity_fingerprint = '$deletedFingerprint'",
) shouldBeExactly 1
}
val rateId = UUID.randomUUID()
connection().use { connection ->
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
INSERT INTO credit_rate_versions (
id, kind, provider, model, effective_from,
asr_credits_numerator, asr_millis_denominator
) VALUES (
'$rateId', 'ASR', 'integration-provider', 'integration-model',
DATE_SUB(CURRENT_TIMESTAMP(6), INTERVAL 1 MINUTE), 1, 100
)
""".trimIndent(),
)
}
}
val transactions = ExposedBillingTransactionRunner(factory.database)
val credits = CreditService(
transactions,
ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30),
)
val concurrentUser = UUID.randomUUID()
connection().use {
insertAccount(it, concurrentUser, "concurrent-sub", identity.ofAppleSubject("concurrent-sub"))
}
credits.grantSignupTrial(concurrentUser, 100, "integration-signup-concurrent")
val reservationResults = coroutineScope {
listOf("integration-reserve-one", "integration-reserve-two").map { key ->
async(Dispatchers.Default) {
runCatching {
credits.reserve(
concurrentUser,
"integration-provider",
"integration-model",
UsageMeasurement.Asr(6_000),
managedCall = true,
idempotencyKey = key,
)
}
}
}.awaitAll()
}
withClue(
reservationResults.joinToString { result ->
result.exceptionOrNull()?.let { "${it::class.simpleName}: ${it.message}" } ?: "success"
},
) {
reservationResults.count { it.isSuccess } shouldBeExactly 1
}
credits.getAccount(concurrentUser).balance shouldBeExactly 40
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
connection().use {
insertAccount(it, inviter, "inviter-sub", identity.ofAppleSubject("inviter-sub"))
insertAccount(it, invitee, "invitee-sub", identity.ofAppleSubject("invitee-sub"))
}
val referrals = ReferralService(
transactions = transactions,
registrationTimeProvider = UserRegistrationTimeProvider { accountId ->
accountRepository.findById(accountId)?.createdAt
},
riskProvider = ReferralRiskProvider { accountId ->
accountRepository.findById(accountId)?.let {
ReferralRiskIdentity(it.identityFingerprint, it.antiAbuseRestricted)
}
},
bindingWindow = Duration.ofDays(7),
)
val code = referrals.getOrCreateCode(inviter)
referrals.bind(invitee, code.code)
credits.grantSignupTrial(invitee, 100, "integration-signup-invitee")
val referralReservation = credits.reserve(
invitee,
"integration-provider",
"integration-model",
UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "integration-referral-reserve",
)
coroutineScope {
List(2) {
async(Dispatchers.Default) {
credits.settle(
invitee,
referralReservation.id,
UsageMeasurement.Asr(500),
"integration-referral-settle",
)
}
}.awaitAll()
}
shouldThrow<CreditConflict> {
credits.settle(
invitee,
referralReservation.id,
UsageMeasurement.Asr(600),
"integration-referral-settle",
)
}
connection().use { connection ->
count(
connection,
"credit_ledger",
"entry_type IN ('REFERRAL_INVITER', 'REFERRAL_INVITEE')",
) shouldBeExactly 2
count(
connection,
"referral_bindings",
"invitee_user_id = '$invitee' AND rewarded_at IS NOT NULL",
) shouldBeExactly 1
}
} finally {
factory.close()
mysql?.stop()
}
}
})
private class KotlinMySqlContainer(image: String) :
MySQLContainer<KotlinMySqlContainer>(image)
private fun KotlinMySqlContainer.connection(): Connection =
DriverManager.getConnection(jdbcUrl, username, password)
private fun insertAccount(
connection: Connection,
id: UUID,
appleSubject: String,
identityFingerprint: String,
) {
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
INSERT INTO accounts (
id, apple_sub, identity_fingerprint, anti_abuse_restricted, created_at, updated_at
) VALUES (
'$id', '$appleSubject', '$identityFingerprint', FALSE,
CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6)
)
""".trimIndent(),
)
}
}
private fun count(connection: Connection, table: String, predicate: String): Int =
connection.createStatement().use { statement ->
statement.executeQuery("SELECT COUNT(*) FROM $table WHERE $predicate").use { result ->
result.next()
result.getInt(1)
}
}