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:
@@ -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,
|
||||
)
|
||||
+55
@@ -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
|
||||
}
|
||||
+674
@@ -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)
|
||||
+448
@@ -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?,
|
||||
)
|
||||
+248
@@ -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)
|
||||
+400
@@ -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
|
||||
}
|
||||
}
|
||||
+485
@@ -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) {
|
||||
'&' -> "&"
|
||||
'<' -> "<"
|
||||
'>' -> ">"
|
||||
'"' -> """
|
||||
'\'' -> "'"
|
||||
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,
|
||||
)
|
||||
+42
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user