From 0af35d44f43ff84b13f044efafa362495c4a5279 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:46:23 +0800 Subject: [PATCH] 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. --- .cursorrules | 72 ++ .dockerignore | 27 + .editorconfig | 17 + .env.example | 62 ++ .github/dependabot.yml | 12 + .github/workflows/ci.yml | 47 ++ .gitignore | 15 + Dockerfile | 25 + README.md | 302 ++++++++ build.gradle.kts | 107 +++ compose.yaml | 91 +++ deploy/1panel/README.md | 17 + deploy/openresty-account.conf | 123 ++++ docs/ARCHITECTURE.md | 66 ++ docs/BACKUP.md | 49 ++ docs/DEPLOYMENT.md | 198 +++++ docs/mysql-minimum-privileges.sql | 59 ++ docs/openapi.yaml | 678 ++++++++++++++++++ gradle.properties | 6 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 10 + gradlew | 248 +++++++ gradlew.bat | 82 +++ settings.gradle.kts | 10 + .../kotlin/com/osglab/account/Application.kt | 511 +++++++++++++ .../osglab/account/common/api/ApiContract.kt | 59 ++ .../osglab/account/common/errors/ApiErrors.kt | 65 ++ .../common/security/FieldEncryption.kt | 61 ++ .../common/security/IdentityFingerprint.kt | 34 + .../account/common/security/SessionJwt.kt | 116 +++ .../account/common/security/TokenSecurity.kt | 40 ++ .../com/osglab/account/config/AppConfig.kt | 519 ++++++++++++++ .../osglab/account/config/DatabaseFactory.kt | 106 +++ .../com/osglab/account/config/HttpPlugins.kt | 57 ++ .../features/account/AccountRepository.kt | 212 ++++++ .../account/features/account/AccountRoutes.kt | 77 ++ .../features/account/AccountService.kt | 177 +++++ .../appleevents/AppleEventRepository.kt | 69 ++ .../features/appleevents/AppleEventRoutes.kt | 50 ++ .../appleevents/AppleEventVerifier.kt | 99 +++ .../auth/AppleIdentityTokenVerifier.kt | 191 +++++ .../account/features/auth/AppleTokenClient.kt | 243 +++++++ .../account/features/auth/AuthRepository.kt | 346 +++++++++ .../account/features/auth/AuthRoutes.kt | 118 +++ .../auth/SessionAccessAuthenticator.kt | 23 + .../account/features/auth/SessionService.kt | 185 +++++ .../features/credits/domain/CreditDomain.kt | 239 ++++++ .../features/credits/models/CreditDtos.kt | 170 +++++ .../repositories/BillingRepositories.kt | 55 ++ .../ExposedBillingTransactionRunner.kt | 674 +++++++++++++++++ .../features/credits/routes/CreditRoutes.kt | 94 +++ .../credits/services/CreditService.kt | 572 +++++++++++++++ .../features/gateway/GatewaySettings.kt | 49 ++ .../gateway/adapters/GatewayAdapters.kt | 172 +++++ .../features/gateway/agent/AgentModels.kt | 21 + .../gateway/asr/AsrStreamingGateway.kt | 328 +++++++++ .../features/gateway/models/GatewayModels.kt | 243 +++++++ .../gateway/polish/DeepSeekGateway.kt | 8 + .../features/gateway/ports/GatewayPorts.kt | 200 ++++++ .../gateway/providers/GatewayProvider.kt | 43 ++ .../providers/deepseek/DeepSeekProvider.kt | 448 ++++++++++++ .../providers/volcengine/SaucV3Protocol.kt | 248 +++++++ .../volcengine/VolcengineAsrProvider.kt | 400 +++++++++++ .../repositories/ExposedGatewayRepository.kt | 485 +++++++++++++ .../features/gateway/routes/GatewayRoutes.kt | 568 +++++++++++++++ .../gateway/services/GatewayGrantService.kt | 247 +++++++ .../gateway/services/GatewayService.kt | 336 +++++++++ .../gateway/services/GatewayUsageEstimator.kt | 46 ++ .../account/features/health/HealthRoutes.kt | 29 + .../account/features/integrity/AppAttest.kt | 674 +++++++++++++++++ .../features/integrity/AppAttestCrypto.kt | 491 +++++++++++++ .../account/features/integrity/DeviceCheck.kt | 499 +++++++++++++ .../features/integrity/IntegrityPorts.kt | 188 +++++ .../integrity/IntegrityVerification.kt | 120 ++++ .../features/inviteweb/InviteWebRoutes.kt | 293 ++++++++ .../referrals/domain/ReferralDomain.kt | 147 ++++ .../features/referrals/models/ReferralDtos.kt | 83 +++ .../repositories/ReferralsRepository.kt | 42 ++ .../referrals/routes/ReferralRoutes.kt | 103 +++ .../referrals/services/ReferralService.kt | 231 ++++++ .../apple/Apple_App_Attestation_Root_CA.pem | 14 + src/main/resources/application.yaml | 61 ++ .../migration/V1__identity_and_sessions.sql | 47 ++ .../migration/V2__credits_and_referrals.sql | 352 +++++++++ ...__gateway_grants_and_provider_requests.sql | 76 ++ .../resources/db/migration/V4__integrity.sql | 51 ++ .../V5__account_security_hardening.sql | 113 +++ .../migration/V6__gateway_execution_state.sql | 65 ++ .../migration/V7__initial_managed_rates.sql | 49 ++ .../invite/apple-app-site-association.json | 17 + src/main/resources/invite/index.html | 184 +++++ src/main/resources/logback.xml | 45 ++ .../com/osglab/account/ApplicationTest.kt | 19 + .../account/common/api/ApiContractTest.kt | 84 +++ .../common/security/SecurityPrimitivesTest.kt | 53 ++ .../osglab/account/config/AppConfigTest.kt | 153 ++++ .../config/DeploymentConsistencyTest.kt | 119 +++ .../features/account/AccountServiceTest.kt | 171 +++++ .../appleevents/AppleEventRepositoryTest.kt | 14 + .../appleevents/AppleEventVerifierTest.kt | 114 +++ .../auth/AppleClientSecretProviderTest.kt | 74 ++ .../auth/AppleIdentityTokenVerifierTest.kt | 180 +++++ .../features/auth/AppleTokenClientTest.kt | 123 ++++ .../auth/SessionAccessAuthenticatorTest.kt | 78 ++ .../features/auth/SessionServiceTest.kt | 405 +++++++++++ .../features/credits/CreditServiceTest.kt | 576 +++++++++++++++ .../features/credits/TestBillingStore.kt | 244 +++++++ .../gateway/asr/AsrStreamingServiceTest.kt | 359 ++++++++++ .../gateway/models/TextRequestPolicyTest.kt | 45 ++ .../providers/deepseek/DeepSeekClientTest.kt | 183 +++++ .../volcengine/SaucV3ProtocolTest.kt | 144 ++++ .../gateway/routes/GatewayRequestIdTest.kt | 158 ++++ .../services/GatewayGrantServiceTest.kt | 228 ++++++ .../services/GatewayReplayStateTest.kt | 231 ++++++ .../services/GatewayServiceBillingTest.kt | 289 ++++++++ .../features/integrity/AppAttestCryptoTest.kt | 196 +++++ .../integrity/AppAttestServiceTest.kt | 229 ++++++ .../BundledAppleAppAttestTrustTest.kt | 14 + .../features/integrity/DeviceCheckTest.kt | 350 +++++++++ .../features/integrity/IntegrityPortsTest.kt | 101 +++ .../integrity/IntegrityServiceTest.kt | 60 ++ .../features/inviteweb/InviteWebRoutesTest.kt | 211 ++++++ .../features/referrals/ReferralServiceTest.kt | 206 ++++++ .../MySqlSecurityIntegrationTest.kt | 340 +++++++++ 124 files changed, 21052 insertions(+) create mode 100644 .cursorrules create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 compose.yaml create mode 100644 deploy/1panel/README.md create mode 100644 deploy/openresty-account.conf create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BACKUP.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/mysql-minimum-privileges.sql create mode 100644 docs/openapi.yaml create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 src/main/kotlin/com/osglab/account/Application.kt create mode 100644 src/main/kotlin/com/osglab/account/common/api/ApiContract.kt create mode 100644 src/main/kotlin/com/osglab/account/common/errors/ApiErrors.kt create mode 100644 src/main/kotlin/com/osglab/account/common/security/FieldEncryption.kt create mode 100644 src/main/kotlin/com/osglab/account/common/security/IdentityFingerprint.kt create mode 100644 src/main/kotlin/com/osglab/account/common/security/SessionJwt.kt create mode 100644 src/main/kotlin/com/osglab/account/common/security/TokenSecurity.kt create mode 100644 src/main/kotlin/com/osglab/account/config/AppConfig.kt create mode 100644 src/main/kotlin/com/osglab/account/config/DatabaseFactory.kt create mode 100644 src/main/kotlin/com/osglab/account/config/HttpPlugins.kt create mode 100644 src/main/kotlin/com/osglab/account/features/account/AccountRepository.kt create mode 100644 src/main/kotlin/com/osglab/account/features/account/AccountRoutes.kt create mode 100644 src/main/kotlin/com/osglab/account/features/account/AccountService.kt create mode 100644 src/main/kotlin/com/osglab/account/features/appleevents/AppleEventRepository.kt create mode 100644 src/main/kotlin/com/osglab/account/features/appleevents/AppleEventRoutes.kt create mode 100644 src/main/kotlin/com/osglab/account/features/appleevents/AppleEventVerifier.kt create mode 100644 src/main/kotlin/com/osglab/account/features/auth/AppleIdentityTokenVerifier.kt create mode 100644 src/main/kotlin/com/osglab/account/features/auth/AppleTokenClient.kt create mode 100644 src/main/kotlin/com/osglab/account/features/auth/AuthRepository.kt create mode 100644 src/main/kotlin/com/osglab/account/features/auth/AuthRoutes.kt create mode 100644 src/main/kotlin/com/osglab/account/features/auth/SessionAccessAuthenticator.kt create mode 100644 src/main/kotlin/com/osglab/account/features/auth/SessionService.kt create mode 100644 src/main/kotlin/com/osglab/account/features/credits/domain/CreditDomain.kt create mode 100644 src/main/kotlin/com/osglab/account/features/credits/models/CreditDtos.kt create mode 100644 src/main/kotlin/com/osglab/account/features/credits/repositories/BillingRepositories.kt create mode 100644 src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt create mode 100644 src/main/kotlin/com/osglab/account/features/credits/routes/CreditRoutes.kt create mode 100644 src/main/kotlin/com/osglab/account/features/credits/services/CreditService.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/GatewaySettings.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/adapters/GatewayAdapters.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/agent/AgentModels.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/asr/AsrStreamingGateway.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/polish/DeepSeekGateway.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/ports/GatewayPorts.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/providers/GatewayProvider.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekProvider.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/SaucV3Protocol.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/VolcengineAsrProvider.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/repositories/ExposedGatewayRepository.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/services/GatewayGrantService.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/services/GatewayService.kt create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/services/GatewayUsageEstimator.kt create mode 100644 src/main/kotlin/com/osglab/account/features/health/HealthRoutes.kt create mode 100644 src/main/kotlin/com/osglab/account/features/integrity/AppAttest.kt create mode 100644 src/main/kotlin/com/osglab/account/features/integrity/AppAttestCrypto.kt create mode 100644 src/main/kotlin/com/osglab/account/features/integrity/DeviceCheck.kt create mode 100644 src/main/kotlin/com/osglab/account/features/integrity/IntegrityPorts.kt create mode 100644 src/main/kotlin/com/osglab/account/features/integrity/IntegrityVerification.kt create mode 100644 src/main/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutes.kt create mode 100644 src/main/kotlin/com/osglab/account/features/referrals/domain/ReferralDomain.kt create mode 100644 src/main/kotlin/com/osglab/account/features/referrals/models/ReferralDtos.kt create mode 100644 src/main/kotlin/com/osglab/account/features/referrals/repositories/ReferralsRepository.kt create mode 100644 src/main/kotlin/com/osglab/account/features/referrals/routes/ReferralRoutes.kt create mode 100644 src/main/kotlin/com/osglab/account/features/referrals/services/ReferralService.kt create mode 100644 src/main/resources/apple/Apple_App_Attestation_Root_CA.pem create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/db/migration/V1__identity_and_sessions.sql create mode 100644 src/main/resources/db/migration/V2__credits_and_referrals.sql create mode 100644 src/main/resources/db/migration/V3__gateway_grants_and_provider_requests.sql create mode 100644 src/main/resources/db/migration/V4__integrity.sql create mode 100644 src/main/resources/db/migration/V5__account_security_hardening.sql create mode 100644 src/main/resources/db/migration/V6__gateway_execution_state.sql create mode 100644 src/main/resources/db/migration/V7__initial_managed_rates.sql create mode 100644 src/main/resources/invite/apple-app-site-association.json create mode 100644 src/main/resources/invite/index.html create mode 100644 src/main/resources/logback.xml create mode 100644 src/test/kotlin/com/osglab/account/ApplicationTest.kt create mode 100644 src/test/kotlin/com/osglab/account/common/api/ApiContractTest.kt create mode 100644 src/test/kotlin/com/osglab/account/common/security/SecurityPrimitivesTest.kt create mode 100644 src/test/kotlin/com/osglab/account/config/AppConfigTest.kt create mode 100644 src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/account/AccountServiceTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/appleevents/AppleEventRepositoryTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/appleevents/AppleEventVerifierTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/auth/AppleClientSecretProviderTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/auth/AppleIdentityTokenVerifierTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/auth/AppleTokenClientTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/auth/SessionAccessAuthenticatorTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/auth/SessionServiceTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/credits/CreditServiceTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/asr/AsrStreamingServiceTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekClientTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/providers/volcengine/SaucV3ProtocolTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/services/GatewayGrantServiceTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/services/GatewayReplayStateTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/integrity/AppAttestCryptoTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/integrity/AppAttestServiceTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/integrity/BundledAppleAppAttestTrustTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/integrity/DeviceCheckTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/integrity/IntegrityPortsTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/integrity/IntegrityServiceTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutesTest.kt create mode 100644 src/test/kotlin/com/osglab/account/features/referrals/ReferralServiceTest.kt create mode 100644 src/test/kotlin/com/osglab/account/integration/MySqlSecurityIntegrationTest.kt diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..b55c43d --- /dev/null +++ b/.cursorrules @@ -0,0 +1,72 @@ +OSGAccountServer is a private backend for OSGKeyboard. + +- Use Kotlin 2.1+ with Ktor 3 and JDK 21. +- Organize code by business feature. +- Route handlers only validate and map HTTP. +- Services own business rules; repositories own persistence. +- Use constructor injection and explicit interfaces at external boundaries. +- Never log credentials, Apple subjects, audio, prompts, transcripts, or model output. +- Store monetary-like credit values as integers and use immutable ledger entries. +- Require idempotency for credit grants, reservations, settlement, refunds, and StoreKit events. +- Keep BYOK and local OSGKeyboard features independent from this service. +- Apply SOLID, DRY, KISS, YAGNI, and OWASP guidance. +- Add tests for success, failure, replay, concurrency, and boundary conditions. +- Do not commit secrets or production identifiers beyond public bundle/domain names. +# OSGAccountServer engineering rules + +- Use Kotlin 2.1.20+, Ktor 3, JDK 21 and Gradle Kotlin DSL. +- Organize code by business feature under `features/`. +- Keep route handlers limited to request/response mapping. +- Put business rules in services and persistence in repositories. +- Use constructor injection and small, testable interfaces. +- Follow SOLID, DRY, KISS, YAGNI and OWASP guidance. +- Never log credentials, Apple identifiers, audio, prompts, transcripts or model output. +- Never store provider API keys in source control or container images. +- Use integer credits. Never use floating point for balances or billing. +- Credit mutations must be transactional, append-only and idempotent. +- Provider failures must release reserved credits; balances must never become negative. +- Sign in with Apple tokens must be verified server-side, including signature, issuer, + audience, expiry and nonce. +- Existing referral rewards require a qualified usage event; registration alone is not + sufficient for the main reward. +- Public APIs require validation, rate limiting and stable error codes. +- Database migrations are immutable after release. +- Tests must cover success, failure, replay, concurrency and boundary conditions. +- Do not expose MySQL or internal admin routes to the public network. +# OSGAccountServer + +- Use Kotlin 2.1+ with JDK 21, Ktor, kotlinx.serialization, Exposed, HikariCP, Koin, and Kotest. +- Organize code by business feature under `features/`; keep routes thin, business logic in services, and persistence behind repositories. +- Apply SOLID, DRY, KISS, YAGNI, and OWASP practices. +- Store money-like credits as integer units and update balances only through immutable, idempotent ledger transactions. +- Never log tokens, Apple subjects, provider credentials, audio, prompts, transcripts, or generated responses. +- Keep all external providers behind interfaces and use deterministic fakes in tests. +- Require tests for success, validation, replay, concurrency, timeout, and failure/refund paths. +- Do not add a dependency unless the standard library or existing stack cannot solve the problem clearly. +# OSGAccountServer + +- Use Kotlin 2.1+ with Ktor 3, JDK 21, and Gradle Kotlin DSL. +- Organize code by business feature: auth, account, credits, referrals, integrity, gateway. +- Keep routes limited to transport concerns; put business rules in services and persistence in repositories. +- Use constructor injection and interfaces at every external boundary. +- Follow SOLID, DRY, KISS, YAGNI, and OWASP guidance. +- Store no audio, prompts, transcripts, or generated response bodies. +- Never log secrets, Apple subjects, credentials, tokens, or user content. +- Keep all credit amounts as integers. Ledger rows are immutable and all writes are idempotent. +- Use database transactions and row locks for balance, reservation, settlement, and referral rewards. +- Require tests for success, failure, replay, concurrency, boundary, and provider-timeout paths. +- Keep public APIs versioned and document them in OpenAPI before mobile integration. +- Do not commit environment files, private keys, provider credentials, or production identifiers. +# OSGAccountServer + +- Use Kotlin 2.1.20 or newer, Ktor 3, JDK 21, and Gradle Kotlin DSL. +- Organize code by business feature under `features/`. +- Route handlers only validate transport input and produce responses. +- Services own business rules; repositories own database access. +- Use constructor injection and explicit interfaces at external boundaries. +- Store money-like credits as integers and preserve an immutable ledger. +- Make every externally retried mutation idempotent. +- Never log tokens, Apple subjects, audio, prompts, transcripts, or model responses. +- Keep provider credentials and signing keys in environment-backed secret storage. +- Follow SOLID, DRY, KISS, YAGNI, and OWASP guidance. +- Add tests for success, validation, replay, concurrency, timeout, and rollback paths. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ee15f34 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +.git +.github +.gradle +.idea +.kotlin +.cursor +build +out +secrets +.env +.env.* +!.env.example +*.iml +*.log +*.p8 +*.pem +!src/main/resources/apple/Apple_App_Attestation_Root_CA.pem +*.key +*.crt +*.sqlite +*.db +.classpath +.project +.settings +compose.override.yml +docker-compose.override.yml +.DS_Store diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..76c1ed6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +indent_style = space +indent_size = 4 +continuation_indent_size = 4 +max_line_length = 120 + +[*.{yaml,yml,json}] +indent_style = space +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..79df78b --- /dev/null +++ b/.env.example @@ -0,0 +1,62 @@ +# Copy to an untracked .env. Every secret below is a placeholder. +APP_ENV=development +PORT=8080 +PUBLIC_BASE_URL=https://account.osglab.com +INVITE_BASE_URL=https://osglab.com/i +APP_STORE_URL=https://apps.apple.com/app/id0000000000 +IMAGE_TAG=local +ACCOUNT_BIND_PORT=18080 +ACCOUNT_DOCKER_NETWORK=account-backend + +# Existing isolated MySQL 8.4 database. +DATABASE_URL=jdbc:mysql://mysql:3306/osg_account?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true +DATABASE_USER=osg_account +DATABASE_PASSWORD=replace-with-a-random-password +DATABASE_POOL_SIZE=10 +DATABASE_MIGRATION_USER=osg_account_migrator +DATABASE_MIGRATION_PASSWORD=replace-with-a-separate-migration-password + +# Generate three independent secrets. Never reuse any of them. +JWT_ISSUER=https://account.osglab.com +JWT_AUDIENCE=osgkeyboard-ios +JWT_SECRET=replace-with-at-least-32-random-bytes +ACCESS_TOKEN_MINUTES=15 +REFRESH_TOKEN_DAYS=30 +GATEWAY_GRANT_DAYS=30 +FIELD_ENCRYPTION_KEY=replace-with-exactly-32-random-bytes-as-base64 +IDENTITY_HMAC_KEY=replace-with-a-distinct-32-random-bytes-as-base64 +IDENTITY_TOMBSTONE_RETENTION_DAYS=365 + +# Apple identifiers are not secrets, but use the values from your own developer account. +APPLE_TEAM_ID=replace-with-apple-team-id +APPLE_KEY_ID=replace-with-apple-key-id +APPLE_CLIENT_ID=replace.with.your.bundle.id +# Encode PEM newlines as literal \n when supplied through Compose/1Panel. +APPLE_PRIVATE_KEY_PEM=replace-with-p8-content-using-literal-backslash-n +APPLE_JWKS_URL=https://appleid.apple.com/auth/keys +APPLE_TOKEN_URL=https://appleid.apple.com/auth/token +APPLE_REVOKE_URL=https://appleid.apple.com/auth/revoke +APPLE_INTEGRITY_ENVIRONMENT=development +APP_ATTEST_CHALLENGE_TTL_SECONDS=300 +# DeviceCheck reuses the configured Apple Team ID, Key ID and ES256 private key. + +# Prefer the newer Volcengine API key. The legacy app ID/access token pair is optional. +VOLCENGINE_API_KEY=replace-with-volcengine-api-key +VOLCENGINE_APP_ID= +VOLCENGINE_ACCESS_TOKEN= +VOLCENGINE_RESOURCE_ID=volc.seedasr.sauc.duration +VOLCENGINE_ASR_ENDPOINT=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async + +DEEPSEEK_API_KEY=replace-with-deepseek-api-key +DEEPSEEK_MODEL=deepseek-v4-flash +DEEPSEEK_ENDPOINT=https://api.deepseek.com/v1 + +SIGNUP_TRIAL_CREDITS=1000 +REFERRAL_INVITER_CREDITS=3000 +REFERRAL_INVITEE_CREDITS=3000 +REFERRAL_BINDING_DAYS=7 + +# Production startup requires both flags and the production Apple environment. +ENFORCE_DEVICE_CHECK=false +ENFORCE_APP_ATTEST=false +LOG_LEVEL=INFO diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f7682a5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: gradle + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..15e74d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - uses: gradle/actions/setup-gradle@v4 + - name: Verify Docker for MySQL integration tests + run: docker info + - name: Validate production Compose + env: + APP_STORE_URL: https://apps.apple.com/app/id1234567890 + DATABASE_URL: jdbc:mysql://mysql:3306/osg_account + DATABASE_USER: runtime + DATABASE_PASSWORD: runtime-password + DATABASE_MIGRATION_USER: migrator + DATABASE_MIGRATION_PASSWORD: migration-password + JWT_SECRET: 01234567890123456789012345678901 + FIELD_ENCRYPTION_KEY: BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc= + IDENTITY_HMAC_KEY: CAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg= + APPLE_TEAM_ID: X329MZU23S + APPLE_KEY_ID: CI_KEY + APPLE_PRIVATE_KEY_PEM: ci-private-key-placeholder + VOLCENGINE_API_KEY: ci-volcengine-key + DEEPSEEK_API_KEY: ci-deepseek-key + run: docker compose -f compose.yaml config --quiet + - name: Test + run: ./gradlew --no-daemon clean test + - name: Build deployable JAR + run: ./gradlew --no-daemon buildFatJar + - name: Build container + run: docker build -t osg-account-server:ci . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fefe3cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.gradle/ +.idea/ +.kotlin/ +build/ +out/ +.DS_Store +.env +*.local +*.iml +*.log +*.p8 +*.jks +*.keystore +docker/mysql/ +secrets/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b81fce6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM gradle:9.6.1-jdk21-alpine AS build +WORKDIR /workspace + +COPY --chown=gradle:gradle . . +USER gradle +RUN ./gradlew --no-daemon --stacktrace installDist + +FROM eclipse-temurin:21-jre-alpine +RUN addgroup -S -g 10001 app \ + && adduser -S -D -H -u 10001 -G app -h /app app +WORKDIR /app + +COPY --from=build --chown=app:app /workspace/build/install/OSGAccountServer/ /app/ + +ENV HOME=/tmp \ + JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=/tmp -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError" + +USER 10001:10001 +EXPOSE 8080 +STOPSIGNAL SIGTERM + +HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/health/live || exit 1 + +ENTRYPOINT ["/app/bin/OSGAccountServer"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3af9c4 --- /dev/null +++ b/README.md @@ -0,0 +1,302 @@ +# OSG Account Server + +Kotlin 2.4.10 / Ktor 3.5.2 managed AI gateway and invitation page. + +## Included + +- Controlled `polish`, `ai`, and `agent` requests through DeepSeek's OpenAI-compatible API. +- Volcengine SAUC v3 ASR over WebSocket with a strict binary frame codec. +- Credit reservation, settlement, release, identity, grant, and metadata persistence ports. +- Apple DeviceCheck trial enforcement and App Attest attestation/assertion validation. +- Bilingual invitation page at `GET /i/{code}` with no analytics, tracking, or fingerprinting. +- Flyway migrations for provider metadata, metered usage, gateway grants, and integrity state. + +Prompts, audio, transcripts, and provider response bodies are never sent to the usage persistence port. +Application logging must also keep request/response body logging disabled. + +## HTTP API + +- `GET /v1/gateway/catalog` +- `POST /v1/gateway/grants`, `POST /v1/gateway/grants/refresh`, `DELETE /v1/gateway/grants/{grantId}` +- `POST /v1/gateway/llm/{polish|ai|agent}` +- `POST /v1/gateway/asr` +- `POST /v1/gateway/asr/sessions`, `GET (WebSocket) /v1/gateway/asr/sessions/{sessionId}/stream` +- `POST /v1/integrity/challenges` +- `POST /v1/integrity/attest` +- `POST /v1/integrity/assert` +- `POST /v1/auth/apple` +- `POST /v1/auth/refresh`, `POST /v1/auth/logout` +- `GET/DELETE /v1/account` +- `POST /v1/apple/events` +- `GET /v1/credits/{balance|ledger|rates}` +- `GET/POST /v1/referrals...` +- `GET /health`, `GET /health/live`, `GET /health/ready` +- `GET /i/{code}` +- `GET /.well-known/apple-app-site-association` +- `GET /apple-app-site-association` + +See `docs/openapi.yaml` for request limits and response formats. + +## Local development + +JDK 21 is required. Start a local MySQL 8.4 database, copy `.env.example` to an untracked `.env`, +replace every required placeholder, then export it into the shell before starting Ktor: + +```bash +set -a +source .env +set +a +./gradlew test +./gradlew run +curl --fail http://127.0.0.1:8080/health/live +``` + +Development may set `APP_ENV=development`, `APPLE_INTEGRITY_ENVIRONMENT=development`, and both +integrity enforcement flags to `false`. Production refuses to start with those values. Never commit +`.env`, `.p8` files, API keys, tokens, database dumps, or generated response data. + +## Application wiring + +`Application.kt` is the composition root. It installs Ktor security, serialization, authentication, +rate limiting, WebSockets, error handling and Koin; initializes Flyway/Exposed; and mounts auth, +account, Apple event, credit, referral, gateway, invitation and health routes. + +The first-party invitation page uses the registered read-only `ReferralLookupPort` adapter. It +preserves case and checks code existence plus campaign validity; the composition root mounts exactly +one `/i/{code}` route. + +Gateway identity uses the signed session JWT and verifies the account plus session family in MySQL on +every authenticated request. Logout, refresh-token replay response, Apple events, and account deletion +therefore invalidate access tokens immediately. Gateway grants, provider metadata and metered usage are +persisted in MySQL. Managed calls reserve, settle or release credits through the same immutable credit +ledger exposed read-only by the public credit API. Clients create scope-limited, time-limited gateway +grants explicitly. Signup trial credits are granted only after DeviceCheck bit0 is conservatively marked. + +## Apple integrity + +DeviceCheck uses Apple's official `query_two_bits` and `update_two_bits` endpoints. ES256 bearer JWTs +are created from `APPLE_TEAM_ID`, `APPLE_KEY_ID`, and `APPLE_PRIVATE_KEY_PEM`; select the matching +development or production endpoint with `APPLE_INTEGRITY_ENVIRONMENT`. Bit0 means the device has +consumed its signup trial, while bit1 is preserved. The plaintext device token is never persisted or +logged. Because DeviceCheck tokens are ephemeral, a MySQL named lock serializes the complete +query-update-confirm window across all server instances. Apple is marked and confirmed before the +idempotent credit entry; an interruption may conservatively forfeit a trial but cannot duplicate one. + +App Attest is bound to the configured Apple team, bundle, and environment. Cryptographic attestation, +receipt, assertion, and certificate-chain validation use the +official Apple App Attestation Root CA bundled from Apple Certificate Authority. The server stores the +validated public key, receipt, and strictly increasing assertion counter. + +Request an `attestation` challenge after `generateKey`, then call `/attest` with the resulting CBOR +object. For login assertions, request an `assertion` challenge and generate the assertion over SHA-256 +of the canonical UTF-8 payload documented in `docs/openapi.yaml`. Challenges are single-use and expire +after `APP_ATTEST_CHALLENGE_TTL_SECONDS`. + +`ENFORCE_DEVICE_CHECK` and `ENFORCE_APP_ATTEST` fail closed for missing or unavailable evidence. +`MONITOR` permits login during rollout or Apple outages, but unavailable DeviceCheck evidence never +qualifies for trial credits. Cryptographically rejected evidence is never allowed by either mode. +Production refuses to start unless both policies are `ENFORCE` and the production Apple environment is +selected. + +## Account deletion and anti-abuse retention + +Account deletion requires a fresh matching Sign in with Apple identity token, authorization code, and +nonce. After reauthentication succeeds, local deletion is committed before the Apple revocation call. +Sessions, referral state, credit balances and reservations, gateway grants, and provider metadata are +removed through database cascades. The newly issued Apple refresh token moves encrypted into a durable +outbox; a successful revoke clears it immediately, while failures retry with bounded exponential backoff. + +The immutable credit ledger is retained with only the now-unmapped random account UUID for financial +auditability. To prevent deletion/recreation trial and self-referral abuse, the server retains only an +HMAC-SHA-256 identity tombstone—never the Apple subject—for +`IDENTITY_TOMBSTONE_RETENTION_DAYS` (365 days by default). `IDENTITY_HMAC_KEY` is a separate secret and +must not reuse JWT or field-encryption keys. Tombstoned identities may sign in but cannot receive another +signup trial or participate in referrals during the retention window. + +Apple server events accept both the documented `account-delete` name and the previously observed +`account-deleted` variant. + +Gateway execution and settlement rules: + +- `X-Request-ID` is atomically claimed within the authenticated account and can never invoke a + provider twice. Replays return `409`, including claimed, started, pending, settled, and released calls. +- LLM requests reserve a UTF-8 byte upper bound plus maximum output tokens, then require internally + consistent provider input, output, and total token usage. +- Raw PCM/WAV reservations are derived from audio bytes. Compressed audio reserves the full accepted + ten-minute boundary because its duration cannot be proven from byte length alone. +- Only failures before provider completion release a reservation. +- Provider success is durably moved to `SETTLEMENT_PENDING` before settlement. Settlement failures keep + credits frozen and the background reconciliation job retries idempotently. +- A successful settlement is returned to the client even if usage metadata needs a later retry. +- Settlement retries must repeat the same actual usage; parameter drift returns a conflict. +- Public idempotency keys are SHA-256 namespaced before storage and cannot collide with internal + referral, signup-trial, or gateway ledger keys. + +## Provider configuration + +Existing `application.yaml` values can be mapped into: + +- `DeepSeekConfig(endpoint, apiKey, model)` +- `VolcengineAsrConfig(endpoint, resourceId, appId, accessToken)` +- `InviteWebConfig(appStoreUrl, appleAppId, universalLinkBaseUrl)` + +`VolcengineAsrConfig` also supports the newer single `apiKey` credential when the application +configuration exposes it. Missing credentials, non-TLS provider URLs, missing resource IDs, malformed +frames, provider error frames, missing final frames, and missing metered duration all fail closed. +Production additionally requires the exact provider hosts `api.deepseek.com` and +`openspeech.bytedance.com` on the default TLS port. + +Configuration ownership: + +- Apple: `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID`, `APPLE_PRIVATE_KEY_PEM`, + `APPLE_INTEGRITY_ENVIRONMENT`, plus the two integrity enforcement flags. +- Volcengine: prefer `VOLCENGINE_API_KEY`; set the SAUC v3 `VOLCENGINE_RESOURCE_ID` and WSS + `VOLCENGINE_ASR_ENDPOINT`. The legacy app ID/access token pair remains optional. +- DeepSeek: set `DEEPSEEK_API_KEY`, the provisioned `DEEPSEEK_MODEL`, and HTTPS + `DEEPSEEK_ENDPOINT`. + +Store production values in 1Panel's secret/environment facility. The Compose environment receives +them at runtime because this application does not read Docker `/run/secrets/*` files directly. + +## SAUC v3 protocol boundary + +The endpoint uses the SAUC v3 API, while the documented binary header version nibble remains `1`. +The codec implements: + +- 4-byte base header and big-endian integer fields. +- Full-client JSON request (`0x1`) with gzip. +- Audio-only request (`0x2`) with gzip and final-packet flag `0x2`. +- Full-server response (`0x9`) with a required strictly increasing sequence and negative final sequence. +- Error response (`0xF`) with error-code field. +- Strict declared-size, payload-limit, gzip, message-type, and final-frame validation. + +The client supports `pcm`, `wav`, `ogg`, and `mp3`; 16 kHz, 16-bit, one or two channels; and a +maximum duration of ten minutes. Only the final frame can supply billable `audio_info.duration`. +Clients may use the buffered HTTP endpoint or create a one-shot downstream WebSocket session. Both +paths durably claim the request ID before contacting Volcengine and share the same reservation and +settlement state machine. + +## Database + +The application runs Flyway against the existing MySQL 8.4 instance before opening the runtime pool. +V1-V7 remain append-only migration history; the invitation web feature requires no new table and does +not modify V4. `V5__account_security_hardening.sql` adds account +foreign keys, identity tombstones, and the encrypted Apple revocation outbox. +`V6__gateway_execution_state.sql` scopes request IDs by account, binds provider requests to credit +reservations, and stores only metering metadata needed for settlement reconciliation. No table has a +request-body, prompt, audio, transcript, or response-body column. + +## Container deployment + +The production Compose file starts only `account-server`; it does not create a second MySQL. +The application port is bound to `127.0.0.1:18080`, and OpenResty is the only public entry point. + +Local build: + +```bash +./gradlew clean build +docker build -t osg-account-server:local . +``` + +Deployment: + +```bash +# Create this only when the existing MySQL is containerized and the network is absent. +docker network create --internal account-backend + +# Put deployment values in an uncommitted .env or 1Panel secret/environment store. +docker compose config +docker compose up -d --build +docker compose ps +curl --fail http://127.0.0.1:18080/health/ready +``` + +If MySQL runs directly on the host or another private server, keep the external network declaration +but set `DATABASE_URL` to a private hostname reachable from that network. The application also joins +the separate egress network for Apple and provider HTTPS calls. Never publish MySQL port 3306 to the +Internet. + +### Required environment + +- Public URLs: `PUBLIC_BASE_URL`, `INVITE_BASE_URL`, `APP_STORE_URL`. +- Existing MySQL: `DATABASE_URL`, runtime `DATABASE_USER` / `DATABASE_PASSWORD`, + migration-only `DATABASE_MIGRATION_USER` / `DATABASE_MIGRATION_PASSWORD`, and + `DATABASE_POOL_SIZE`. + Keep `connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true` in the JDBC URL. +- Session and encryption secrets: `JWT_SECRET`, `FIELD_ENCRYPTION_KEY`, `IDENTITY_HMAC_KEY`. + The two Base64 keys must be distinct; the field key decodes to exactly 32 bytes. +- Apple: `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID`, `APPLE_PRIVATE_KEY_PEM`, + `APPLE_INTEGRITY_ENVIRONMENT`. +- Providers: `VOLCENGINE_API_KEY`, `VOLCENGINE_RESOURCE_ID`, `DEEPSEEK_API_KEY`, + `DEEPSEEK_MODEL`. +- Production controls: `APP_ENV=production`, `ENFORCE_DEVICE_CHECK=true`, + `ENFORCE_APP_ATTEST=true`. +- Optional tuning: token lifetimes, gateway grant days, credit values, binding window and pool size; + defaults are visible in `compose.yaml`. + +All values in the Compose file are placeholders or environment substitutions. Keep real credentials +out of Git, Compose YAML, 1Panel screenshots and shell history. + +The Apple ES256 `.p8` material is supplied as `APPLE_PRIVATE_KEY_PEM` with literal `\n` line breaks; +the DeviceCheck JWS adapter reuses that configured Team ID, Key ID and private key. Keep Volcengine and +DeepSeek API keys in separate 1Panel secret fields and rotate each provider independently. + +### Existing MySQL minimum privileges + +The server uses a Flyway-only account for migrations and a separately pooled runtime account. Apply +`docs/mysql-minimum-privileges.sql` as an administrator after replacing its private host pattern and +generated passwords. +The runtime account can append to ledger and usage history but cannot update/delete that history or +alter rate versions or schemas. The migration account is scoped to `osg_account.*` and has no global +privileges. + +Replace the example `10.20.%` host pattern with the actual container subnet. Do not grant either account `FILE`, `PROCESS`, +`SUPER`, `CREATE USER`, `GRANT OPTION`, or access from `%`. + +### 1Panel and firewall + +1. Create HTTPS sites for `account.osglab.com` and `osglab.com` in 1Panel. +2. Apply `deploy/openresty-account.conf`, adjusting certificate/root paths when needed. +3. Validate with `openresty -t`; AASA is proxied to Ktor at both extensionless paths. +4. Point both DNS records to the server and reload OpenResty after certificate issuance. +5. Allow inbound TCP 80/443 only; allow SSH only from an administrator IP. Do not expose + 18080, 8080 or 3306. Permit required outbound HTTPS for Apple, DeepSeek and Volcengine. + +See `docs/DEPLOYMENT.md` for rollback, verification and Universal Link checks. + +### Invitation link architecture + +Invitation links use the first-party Universal Link `https://osglab.com/i/{code}`. The page contains +no third-party JavaScript, analytics, tracking pixels or external fonts. Firebase Dynamic Links and +Branch are deliberately not used. + +## Tests + +Run: + +```bash +./gradlew test +``` + +Gateway tests cover account-scoped replay exclusion, cross-account isolation, provider failure release, +settlement pending behavior, reconciliation, whole-call timeout, forged audio duration, malformed SAUC +sequences, and final-frame-only duration. + +The MySQL Testcontainers suite validates all migrations, account-deletion cascades, stale access-token +rejection, real InnoDB concurrent balance locking, and exactly-once referral rewards. It is automatically +skipped when Docker is unavailable; CI requires Docker and runs it on every build. + +## Staged acceptance + +1. Local: tests pass, malformed/unknown/referral-lookup-failure paths fail closed, and the page makes no + third-party requests. +2. Container: UID/GID 10001, read-only root filesystem, dropped capabilities, and both health endpoints + pass. +3. Pre-production: migrations use only the migrator; runtime uses only its table grants; HTTP and WSS + reverse proxy tests pass. +4. Apple: AASA returns JSON without redirects; a physical device opens the Universal Link; an + uninstalled device receives the responsive bilingual page. +5. Production: only 80/443 are public, invitation paths are excluded from access logs, provider + credentials work, and an encrypted backup has been restored successfully in isolation. + +See `docs/DEPLOYMENT.md` for exact rollout commands and `docs/BACKUP.md` for backup and recovery. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0b91447 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,107 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") version "2.4.10" + kotlin("plugin.serialization") version "2.4.10" + id("io.ktor.plugin") version "3.5.2" + application + jacoco +} + +group = "com.osglab.account" +version = "0.1.0" + +repositories { + mavenCentral() +} + +application { + mainClass.set("io.ktor.server.netty.EngineMain") +} + +kotlin { + jvmToolchain(21) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +dependencies { + val ktorVersion = "3.5.2" + val exposedVersion = "1.4.0" + val flywayVersion = "13.3.0" + + implementation("io.ktor:ktor-server-core-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-netty-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktorVersion") + implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-auth-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-auth-jwt-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-status-pages-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-call-logging-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-rate-limit-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-websockets-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-forwarded-header-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-config-yaml-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-call-id-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-default-headers-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-compression-jvm:$ktorVersion") + + implementation("io.ktor:ktor-client-core-jvm:$ktorVersion") + implementation("io.ktor:ktor-client-cio-jvm:$ktorVersion") + implementation("io.ktor:ktor-client-content-negotiation-jvm:$ktorVersion") + implementation("io.ktor:ktor-client-websockets-jvm:$ktorVersion") + + implementation("io.insert-koin:koin-ktor:4.2.2") + implementation("io.insert-koin:koin-logger-slf4j:4.2.2") + + implementation("org.jetbrains.exposed:exposed-core:$exposedVersion") + implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion") + implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion") + implementation("com.zaxxer:HikariCP:7.1.0") + implementation("com.mysql:mysql-connector-j:26.7.0") + implementation("org.flywaydb:flyway-core:$flywayVersion") + implementation("org.flywaydb:flyway-mysql:$flywayVersion") + + implementation("com.auth0:java-jwt:4.6.0") + implementation("com.nimbusds:nimbus-jose-jwt:10.9.1") + implementation("ch.veehait.devicecheck:devicecheck-appattest:0.9.6") + implementation("org.bouncycastle:bcprov-jdk18on:1.85.2") + implementation("com.upokecenter:cbor:4.5.6") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0") + implementation("ch.qos.logback:logback-classic:1.6.3") + + testImplementation("io.ktor:ktor-server-test-host-jvm:$ktorVersion") + testImplementation("io.ktor:ktor-client-mock-jvm:$ktorVersion") + testImplementation(kotlin("test")) + testImplementation("io.kotest:kotest-runner-junit5-jvm:6.2.4") + testImplementation("io.kotest:kotest-assertions-core-jvm:6.2.4") + testImplementation("io.mockk:mockk-jvm:1.14.11") + testImplementation("org.testcontainers:mysql:1.21.4") + testImplementation("org.testcontainers:junit-jupiter:1.21.4") +} + +tasks.test { + useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) +} + +jacoco { + toolVersion = "0.8.15" +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + html.required.set(true) + } +} + +ktor { + fatJar { + archiveFileName.set("osg-account-server.jar") + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..0fc32b1 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,91 @@ +services: + account-server: + build: + context: . + dockerfile: Dockerfile + image: osg-account-server:${IMAGE_TAG:-local} + restart: unless-stopped + init: true + user: "10001:10001" + stop_grace_period: 30s + environment: + APP_ENV: production + PORT: "8080" + PUBLIC_BASE_URL: https://account.osglab.com + INVITE_BASE_URL: https://osglab.com/i + APP_STORE_URL: ${APP_STORE_URL:?set the production App Store HTTPS URL} + + # Reuse the existing MySQL; this stack intentionally creates no database. + DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL for the existing MySQL} + DATABASE_USER: ${DATABASE_USER:?set DATABASE_USER} + DATABASE_PASSWORD: ${DATABASE_PASSWORD:?set DATABASE_PASSWORD} + DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-10} + DATABASE_MIGRATION_USER: ${DATABASE_MIGRATION_USER:?set DATABASE_MIGRATION_USER} + DATABASE_MIGRATION_PASSWORD: ${DATABASE_MIGRATION_PASSWORD:?set DATABASE_MIGRATION_PASSWORD} + + JWT_ISSUER: https://account.osglab.com + JWT_AUDIENCE: ${JWT_AUDIENCE:-osgkeyboard-ios} + JWT_SECRET: ${JWT_SECRET:?set a random JWT secret} + ACCESS_TOKEN_MINUTES: ${ACCESS_TOKEN_MINUTES:-15} + REFRESH_TOKEN_DAYS: ${REFRESH_TOKEN_DAYS:-30} + GATEWAY_GRANT_DAYS: ${GATEWAY_GRANT_DAYS:-30} + FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY:?set a 32-byte Base64 key} + IDENTITY_HMAC_KEY: ${IDENTITY_HMAC_KEY:?set a distinct Base64 key} + IDENTITY_TOMBSTONE_RETENTION_DAYS: ${IDENTITY_TOMBSTONE_RETENTION_DAYS:-365} + + APPLE_TEAM_ID: ${APPLE_TEAM_ID:?set Apple team ID} + APPLE_KEY_ID: ${APPLE_KEY_ID:?set Apple key ID} + APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-com.osgkeyboard.ios} + APPLE_PRIVATE_KEY_PEM: ${APPLE_PRIVATE_KEY_PEM:?set Apple private key PEM} + APPLE_INTEGRITY_ENVIRONMENT: production + APP_ATTEST_CHALLENGE_TTL_SECONDS: ${APP_ATTEST_CHALLENGE_TTL_SECONDS:-300} + ENFORCE_DEVICE_CHECK: "true" + ENFORCE_APP_ATTEST: "true" + + VOLCENGINE_API_KEY: ${VOLCENGINE_API_KEY:?set Volcengine API key} + VOLCENGINE_RESOURCE_ID: ${VOLCENGINE_RESOURCE_ID:-volc.seedasr.sauc.duration} + VOLCENGINE_ASR_ENDPOINT: ${VOLCENGINE_ASR_ENDPOINT:-wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async} + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:?set DeepSeek API key} + DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-flash} + DEEPSEEK_ENDPOINT: ${DEEPSEEK_ENDPOINT:-https://api.deepseek.com/v1} + + SIGNUP_TRIAL_CREDITS: ${SIGNUP_TRIAL_CREDITS:-1000} + REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-3000} + REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-3000} + REFERRAL_BINDING_DAYS: ${REFERRAL_BINDING_DAYS:-7} + ports: + - "127.0.0.1:${ACCOUNT_BIND_PORT:-18080}:8080" + read_only: true + pids_limit: 256 + tmpfs: + - /tmp:size=64m,mode=1777,noexec,nosuid,nodev + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health/ready"] + interval: 30s + timeout: 3s + start_period: 30s + retries: 3 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + networks: + - account-backend + # Separate egress keeps Apple/provider traffic available when the shared + # database network was created with Docker's --internal flag. + - account-egress + +networks: + # Pre-create this shared network with: + # docker network create --internal account-backend + # Attach the existing MySQL container without publishing port 3306. + account-backend: + external: true + name: ${ACCOUNT_DOCKER_NETWORK:-account-backend} + account-egress: + driver: bridge diff --git a/deploy/1panel/README.md b/deploy/1panel/README.md new file mode 100644 index 0000000..2ee1d28 --- /dev/null +++ b/deploy/1panel/README.md @@ -0,0 +1,17 @@ +# 1Panel / OpenResty 示例 + +1. 在 1Panel 创建 `account.osglab.com` 与 `osglab.com` 两个 HTTPS 网站并申请证书。 +2. 将 `deploy/openresty-account.conf` 的 `map`、`limit_req_zone`、`upstream` 放入 + OpenResty `http` 上下文,其余 `server` 块作为站点配置。这是仓库内唯一的反向代理配置源。 +3. 确认证书目录与示例一致;若 1Panel 实际路径不同,以面板生成的路径为准。 +4. AASA 的两个无扩展名路径都直接代理到 Ktor,由生产配置生成 JSON;不要复制或维护静态 + AASA 文件,也不要给 AASA 添加跳转、认证或缓存覆盖。 +5. 先执行 `openresty -t`,配置检查通过后再在 1Panel 重载 OpenResty。 + +示例假定 `compose.yaml` 把应用映射到宿主机 `127.0.0.1:18080`。若 OpenResty +本身运行在容器中,应让它加入 `account-backend` 外部网络,并把 upstream 改为 +`account-server:8080`;不要把应用端口绑定到公网地址。 + +使用 `docker network create --internal account-backend` 创建数据库网络,并将现有 MySQL +容器加入该网络;MySQL 不配置 `ports`。应用同时加入独立 egress 网络访问 Apple、火山 +和 DeepSeek。 diff --git a/deploy/openresty-account.conf b/deploy/openresty-account.conf new file mode 100644 index 0000000..36ec9c8 --- /dev/null +++ b/deploy/openresty-account.conf @@ -0,0 +1,123 @@ +# Include this file from OpenResty's http block. The Compose service listens only +# on 127.0.0.1:18080, so clients cannot bypass these controls. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +limit_req_zone $binary_remote_addr zone=account_api:10m rate=20r/s; +limit_req_zone $binary_remote_addr zone=invite_page:10m rate=5r/s; +limit_req_status 429; + +upstream osg_account_server { + server 127.0.0.1:18080; + keepalive 32; +} + +server { + listen 80; + listen [::]:80; + server_name account.osglab.com; + return 308 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name account.osglab.com; + + ssl_certificate /www/server/panel/vhost/cert/account.osglab.com/fullchain.pem; + ssl_certificate_key /www/server/panel/vhost/cert/account.osglab.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_timeout 1d; + ssl_session_cache shared:account_tls:10m; + + client_max_body_size 21m; + server_tokens off; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer" always; + + # Never publish operational or administrative paths through this vhost. + location ~ ^/(?:admin|internal|v1/admin)(?:/|$) { + return 404; + } + + location / { + limit_req zone=account_api burst=40 nodelay; + proxy_pass http://osg_account_server; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Request-ID $request_id; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_hide_header Server; + proxy_request_buffering off; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 360s; + proxy_send_timeout 360s; + } +} + +server { + listen 80; + listen [::]:80; + server_name osglab.com www.osglab.com; + return 308 https://osglab.com$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name osglab.com www.osglab.com; + + ssl_certificate /www/server/panel/vhost/cert/osglab.com/fullchain.pem; + ssl_certificate_key /www/server/panel/vhost/cert/osglab.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + server_tokens off; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + + location = /.well-known/apple-app-site-association { + access_log off; + proxy_pass http://osg_account_server; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + } + + location = /apple-app-site-association { + access_log off; + proxy_pass http://osg_account_server; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + } + + # Invitation codes are bearer-like values and must not appear in access logs. + location ^~ /i/ { + access_log off; + limit_req zone=invite_page burst=10 nodelay; + proxy_pass http://osg_account_server; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Request-ID $request_id; + proxy_hide_header Server; + proxy_buffering off; + proxy_cache off; + } + + # Keep the existing 1Panel site root for all non-invitation pages. + root /www/wwwroot/osglab.com; + location / { + try_files $uri $uri/ =404; + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..a02ddd6 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,66 @@ +# OSGAccountServer architecture + +OSGAccountServer is a modular Ktor application. It owns OSG accounts, credits, +referrals, integrity checks, and metered access to OSG-hosted providers. + +It does not replace the app's local ASR, BYOK provider access, or iCloud sync. + +## Trust boundaries + +1. Sign in with Apple identifies a user. The service stores only Apple's stable + subject, encrypted Apple refresh credentials, and internal identifiers. +2. The host app owns account-management credentials in its private Keychain. +3. The keyboard extension receives a separately revocable gateway grant. That + grant cannot delete an account, redeem referrals, or mutate credits directly. +4. Provider credentials exist only in the server process. +5. Audio and text are transient request data. They must not be persisted or + included in logs, traces, metrics labels, or error messages. + +## Modules + +- `auth` verifies Apple credentials and manages rotating application sessions. +- `account` reads and deletes accounts. +- `appleevents` handles consent and account lifecycle notifications. +- `credits` owns the immutable ledger and its balance projection. +- `referrals` binds one inviter and awards both users after qualified usage. +- `integrity` evaluates DeviceCheck and App Attest evidence. +- `gateway` proxies Volcengine ASR and DeepSeek text requests. +- `inviteweb` serves the first-party invitation landing page. + +Modules communicate through narrow ports. Provider clients, Apple clients, +integrity clients, clocks, token generators, and repositories are replaceable in +tests. + +## Monetary invariants + +- Credit amounts are signed 64-bit integers; public inputs never accept decimal + credit values. +- Ledger rows are immutable. +- Every mutation has a unique idempotency key. +- Available and reserved balances never become negative. +- Reservation, settlement, release, refund, and referral rewards run in database + transactions with the account rows locked. +- A referral can transition to `rewarded` exactly once. +- Usage records reference the exact rate-card version used for settlement. + +## Hosted request lifecycle + +1. Authenticate a gateway-scoped principal and verify request integrity. +2. Validate payload size and feature-specific policy. +3. Reserve the maximum expected credits. +4. Call the allowlisted upstream provider. +5. Settle from provider usage or server-observed ASR duration. +6. Release on transport/provider failure or empty output. +7. Qualify a pending referral only after a successful non-zero settlement. + +## Data retention + +- No audio, transcript, prompt, context, or generated response body is stored. +- Authentication and integrity payloads are retained only as hashes or validated + claims required for replay protection. +- Usage metadata contains feature, model, metering units, latency, status, and + rate-card version. +- Operational logs use request IDs and internal opaque IDs, never Apple subjects + or bearer credentials. +- Account deletion revokes credentials and removes user-linked records. Only + non-identifying aggregate service metrics may remain. diff --git a/docs/BACKUP.md b/docs/BACKUP.md new file mode 100644 index 0000000..d0bd351 --- /dev/null +++ b/docs/BACKUP.md @@ -0,0 +1,49 @@ +# MySQL 8.4 备份与恢复 + +## 策略 + +- 由 1Panel 每日执行一次完整备份,保留 30 天;备份写入与应用主机隔离的对象存储。 +- MySQL 启用 binary log,至少保留 7 天,用于完整备份后的时间点恢复。 +- 数据库备份和对象存储均启用加密;备份账号只授予备份所需权限。 +- 每月至少在隔离环境恢复一次,并记录恢复点、耗时和校验结果。未做恢复演练的备份不能视为可用。 +- 发布含 Flyway 迁移的镜像前,先完成一次可验证的备份。Flyway 迁移只向前执行。 + +## 命令行完整备份 + +先用 `mysql_config_editor` 为受限备份账号建立本机登录路径,避免把密码写入命令行或脚本: + +```bash +mysql_config_editor set --login-path=osg-backup \ + --host=127.0.0.1 --user=osg_backup --password +``` + +在只允许管理员读取的目录执行一致性备份: + +```bash +umask 077 +mysqldump --login-path=osg-backup \ + --single-transaction --quick --routines --triggers --events \ + --set-gtid-purged=OFF --default-character-set=utf8mb4 \ + osg_account | gzip -9 > "osg_account-$(date -u +%Y%m%dT%H%M%SZ).sql.gz" +``` + +备份任务应在上传后生成 SHA-256 校验值,并按保留策略删除过期副本。不要将备份、登录路径文件或校验清单提交到 Git。 + +## 隔离恢复演练 + +恢复目标必须是新建的隔离 MySQL 8.4 实例,不能覆盖生产库: + +```bash +mysql --login-path=osg-restore -e \ + "CREATE DATABASE osg_account_restore CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci" +gzip -dc osg_account-YYYYMMDDTHHMMSSZ.sql.gz | \ + mysql --login-path=osg-restore osg_account_restore +mysql --login-path=osg-restore osg_account_restore -e \ + "SELECT version, success FROM flyway_schema_history ORDER BY installed_rank" +``` + +恢复后执行应用只读验收、表行数抽样、外键检查和邀请查询测试。时间点恢复应先还原完整备份,再仅回放目标时间之前的 binary log。确认结果后销毁隔离实例及明文恢复文件。 + +## 1Panel + +1Panel 的数据库备份任务使用相同的每日频率、异地存储、加密和保留期。启用任务成功/失败通知,并每月下载一份备份到隔离 MySQL 8.4 完成恢复演练。1Panel 面板、工单和截图中不得出现数据库密码、Apple 私钥或供应商 API Key。 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..c5f8b81 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,198 @@ +# OSG Account Server 部署指南 + +本方案使用一个非 root、只读文件系统兼容的 JDK 21 容器。OpenResty 终止 TLS 并反向代理, +MySQL 复用已有实例。`compose.yaml` 不会创建数据库容器。 + +## 1. 前置条件 + +- `account.osglab.com` 与 `osglab.com` 已解析到 1Panel 主机。 +- Docker Engine、Docker Compose v2 和 1Panel OpenResty 已安装。 +- 已有 MySQL 8 实例可从应用容器通过 Docker 外部网络或私网访问。 +- 服务器时钟同步,Apple、DeepSeek、Volcengine 的出站 HTTPS/WSS 可用。 +- App 的 Associated Domains 包含 `applinks:osglab.com`。 + +邀请使用一方 Universal Link,不依赖 Firebase Dynamic Links 或 Branch。 + +## 2. 注册邀请网页路由 + +网页实现在 `features/inviteweb`,资源在 `src/main/resources/invite/index.html`。组合根应只注册 +一个 `/i/{code}` 路由;不要与旧 `features/invite` 路由同时挂载。`ReferralLookupPort` 是只读、 +大小写敏感的验证边界,适配器应在 referrals 模块中用有界查询检查邀请码及活动状态: + +先在 Koin 注册一个 `ReferralLookupPort` 实现;公共接线会从现有 `AppConfig` 读取 App Store URL、 +邀请域名和 Apple Team ID/bundle ID: + +```kotlin +routing { + configureInviteWebRoutes() +} +``` + +测试或不使用 Koin 的宿主可调用显式重载 +`configureInviteWebRoutes(referralLookup, InviteWebConfig(...))`。 + +邀请码规则与当前生成器一致:16 字节随机值编码为无填充 Base64URL,即固定 22 位、区分大小写, +字符白名单为 `A-Z`、`a-z`、`0-9`、`_`、`-`。结构非法或查无记录均返回相同 404;查询超时或 +失败返回不泄露内部信息的 503。页面和两个 AASA endpoint 均发送 `no-store`。 + +## 3. 准备现有 MySQL + +先创建数据库,字符集使用 `utf8mb4`。使用 `docs/mysql-minimum-privileges.sql` 创建相互独立的 +迁移账号和运行账号,并将示例来源网段替换为实际应用容器网段。不要使用公网来源或 `%`: + +```sql +CREATE DATABASE osg_account + CHARACTER SET utf8mb4 + COLLATE utf8mb4_0900_ai_ci; +``` + +应用启动时使用迁移账号执行 Flyway,随后 Hikari 连接池只使用运行账号。运行账号不能更新或删除 +不可变 ledger/usage 历史,也不能访问 Flyway 元数据或未来未审查的表。禁止授予全局权限、 +`FILE`、`PROCESS`、`SUPER`、`CREATE USER`、`GRANT OPTION`。不要复用 root 或其他应用用户。 + +若 MySQL 是容器,把它和应用接入同一个已存在的外部网络: + +```bash +docker network create account-backend +docker network connect account-backend +``` + +已存在网络时不要重复创建。`DATABASE_URL` 中使用该 MySQL 容器在网络内可解析的名称。若 MySQL +位于私网主机,使用私有 DNS/IP,并在数据库防火墙中只允许应用主机或容器网段。 + +## 4. 配置环境与秘密 + +复制部署所需变量到项目根目录的未跟踪 `.env`,或使用 1Panel 的环境变量/秘密管理。不要把真实 +值写入 YAML、镜像层或 Git。 + +必须设置: + +- `DATABASE_URL=jdbc:mysql://:3306/osg_account?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true` +- `DATABASE_USER`、`DATABASE_PASSWORD` +- `DATABASE_MIGRATION_USER`、`DATABASE_MIGRATION_PASSWORD`:必须与运行账号及其密码不同 +- `JWT_SECRET`:至少 32 个随机字节 +- `FIELD_ENCRYPTION_KEY`:恰好 32 个随机字节的 Base64 +- `IDENTITY_HMAC_KEY`:至少 32 个随机字节的独立 Base64,不得复用字段加密密钥 +- `APPLE_TEAM_ID`、`APPLE_KEY_ID`、`APPLE_PRIVATE_KEY_PEM` +- `VOLCENGINE_API_KEY`、`DEEPSEEK_API_KEY` +- `APP_STORE_URL`:正式 App Store HTTPS 地址;生产 Compose 不接受缺失值 + +生成新秘密的示例: + +```bash +openssl rand -base64 48 +openssl rand -base64 32 +openssl rand -base64 32 +``` + +三个结果分别用于 JWT、字段加密和身份 HMAC,不能相互复用。`APPLE_PRIVATE_KEY_PEM` 可使用包含 +字面量 `\n` 的单行值;应用配置会在内存中还原换行。限制 `.env` 权限: + +```bash +chmod 600 .env +``` + +生产 Compose 已固定 `APP_ENV=production`、Apple production 环境及两项完整性强制开关。 +Apple 配置使用所属开发者账号的 Team ID、Key ID、bundle ID 和 `.p8` 私钥;火山引擎使用 SAUC +v3 WSS endpoint、资源 ID 和 API Key;DeepSeek 使用 HTTPS endpoint、已开通模型名和 API Key。 +三方凭据分别创建、分别轮换,不得复用。 + +## 5. 构建与启动 + +先检查变量插值。`docker compose config` 会展开秘密,不要把输出上传或粘贴到工单: + +```bash +./gradlew test +docker compose config --quiet +docker compose build --pull +docker compose up -d +docker compose ps +curl --fail http://127.0.0.1:18080/health/ready +``` + +容器以 UID/GID 10001 运行,根文件系统只读,仅 `/tmp` 是带大小限制的 tmpfs。应用端口只映射到 +宿主机回环地址,不能改成 `0.0.0.0`。 + +查看日志时不得记录或复制 token、Apple subject、音频、prompt、转录或模型响应: + +```bash +docker compose logs --since=10m account-server +``` + +## 6. 配置 1Panel / OpenResty + +在 1Panel 创建 `account.osglab.com`、`osglab.com` 两个 HTTPS 网站并签发证书。使用 +`deploy/openresty-account.conf`: + +1. 将 `map`、`limit_req_zone`、`limit_req_status`、`upstream` 放入 OpenResty `http` 上下文。 +2. 将三个 `server` 块作为站点配置;按 1Panel 实际证书路径调整 `ssl_certificate`。 +3. 示例 upstream 指向宿主机 `127.0.0.1:18080`。若 OpenResty 自身在容器中,则将其加入 + `account-backend`,并改为 `account-server:8080`。 +4. 配置明确对 `/admin`、`/internal`、`/v1/admin` 返回 404;不要新增绕过该规则的泛域名代理。 +5. API 示例按 IP 限制 20 请求/秒,邀请页限制 5 请求/秒,可基于真实流量谨慎调整。 +6. 代理统一支持 HTTP/1.1 Upgrade/Connection,因此当前 HTTP API 与后续 WebSocket 入口都可用。 + +两个 AASA 地址由 Ktor 根据 `appleAppId` 模板输出,不需要复制静态文件。配置检查成功后再通过 +1Panel 重载 OpenResty: + +```bash +openresty -t +``` + +## 7. 验证 + +检查健康状态、反向代理、隐藏路径、限流和 AASA: + +```bash +curl --fail https://account.osglab.com/health/ready +curl -i https://account.osglab.com/internal/ +curl -i https://osglab.com/.well-known/apple-app-site-association +curl -i https://osglab.com/i/AbCdEf0123456789_-AbCd +``` + +预期结果: + +- 健康检查返回 200。 +- 内部路径返回 404。 +- AASA 返回 200、`Content-Type: application/json`,且不发生重定向。 +- 合法 22 位邀请码返回双语 HTML;非法、未知或失效邀请码统一返回 404。 +- 邀请页包含 nonce CSP、`Cache-Control: no-store`、`X-Robots-Tag`,无第三方请求。 + +使用真机验证 Universal Link。安装带 Associated Domains entitlement 的 App 后,从信息或邮件点击 +`https://osglab.com/i/` 应直接进入 App;未安装时应显示网页。AASA 更新受系统缓存影响, +首次验证应预留传播时间。 + +## 8. 防火墙 + +- 公网入站仅允许 TCP 80/443。 +- SSH 仅允许管理员固定 IP 或 VPN;不公开 18080、8080、3306。 +- MySQL 3306 仅允许 Docker 私网或指定应用主机。 +- 出站允许 DNS、NTP、Apple HTTPS、DeepSeek HTTPS、Volcengine HTTPS/WSS。 +- 云安全组、主机防火墙和 1Panel 防火墙应采用相同边界,避免其中一层意外放行。 + +## 9. 更新与回滚 + +更新前备份 MySQL 并记录当前镜像标签。使用不可变标签构建: + +```bash +IMAGE_TAG= docker compose build +IMAGE_TAG= docker compose up -d +``` + +Flyway 迁移只向前执行。若新版本包含数据库迁移,应用镜像回滚不等于数据库回滚;应先按迁移影响 +制定恢复方案。无数据库变更时,可把 `IMAGE_TAG` 切回上一版本并重新执行 `docker compose up -d`。 + +备份、时间点恢复和每月恢复演练按 `docs/BACKUP.md` 执行。 + +## 10. 分阶段验收 + +1. **本地阶段**:`./gradlew test` 通过;合法、未知、格式错误和 lookup 异常路径符合预期;HTML + 不加载第三方资源。 +2. **容器阶段**:镜像以 UID/GID 10001 运行;根文件系统只读;`/health/live` 与 + `/health/ready` 通过;容器无法取得额外 Linux capability。 +3. **预发布阶段**:Flyway 使用迁移账号成功,应用使用运行账号成功;撤销运行账号 DDL 权限后服务 + 仍正常;OpenResty HTTP、HTTPS 和 WSS 代理验证通过。 +4. **Apple 阶段**:两个 AASA 地址返回无重定向 JSON;真机从信息或邮件点击 + `https://osglab.com/i/` 可打开 App;未安装 App 时显示双语落地页。 +5. **生产阶段**:公网仅开放 80/443,3306/8080/18080 不可达;邀请 URL 不出现在访问日志; + 供应商凭据可用;完成加密备份并记录一次隔离恢复结果。 diff --git a/docs/mysql-minimum-privileges.sql b/docs/mysql-minimum-privileges.sql new file mode 100644 index 0000000..e4459be --- /dev/null +++ b/docs/mysql-minimum-privileges.sql @@ -0,0 +1,59 @@ +-- Run as a MySQL administrator after replacing the host pattern and generated +-- passwords. Keep both users restricted to the private application subnet. +CREATE USER 'osg_account_runtime'@'10.20.%' + IDENTIFIED BY 'REPLACE_WITH_RUNTIME_PASSWORD'; +CREATE USER 'osg_account_migrator'@'10.20.%' + IDENTIFIED BY 'REPLACE_WITH_MIGRATION_PASSWORD'; + +-- Flyway owns schema evolution. This account is not used by the Hikari runtime pool. +GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, REFERENCES, TRIGGER + ON osg_account.* TO 'osg_account_migrator'@'10.20.%'; + +-- Runtime reads are explicit so the account cannot read Flyway metadata or future +-- tables until an administrator reviews and grants access. +GRANT SELECT ON osg_account.accounts TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.apple_credentials TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.sessions TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.apple_event_receipts TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.credit_accounts TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.credit_rate_versions TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.credit_reservations TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.referral_campaigns TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.referral_campaign_budgets TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.referral_codes TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.referral_bindings TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.credit_usage_records TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.credit_ledger TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.provider_requests TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.usage_records TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.gateway_grants TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%'; +GRANT SELECT ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%'; + +GRANT INSERT, UPDATE, DELETE ON osg_account.accounts TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.apple_credentials TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.sessions TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT ON osg_account.apple_event_receipts TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.credit_accounts TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.credit_reservations TO 'osg_account_runtime'@'10.20.%'; +GRANT UPDATE ON osg_account.referral_campaign_budgets TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT ON osg_account.referral_codes TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.referral_bindings TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT ON osg_account.credit_usage_records TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT ON osg_account.credit_ledger TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.provider_requests TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT ON osg_account.usage_records TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.gateway_grants TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%'; +GRANT INSERT, UPDATE ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%'; + +-- Deliberately absent: global privileges, GRANT OPTION, FILE, PROCESS, SUPER, +-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables. +SHOW GRANTS FOR 'osg_account_runtime'@'10.20.%'; +SHOW GRANTS FOR 'osg_account_migrator'@'10.20.%'; diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..2bf260d --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,678 @@ +openapi: 3.1.0 +info: + title: OSG Account Server API + version: 0.1.0 + description: | + Account, credit, referral, integrity, and managed AI APIs for OSGKeyboard. + Local features and user-owned API keys remain independent of this service. +servers: + - url: https://account.osglab.com +security: + - bearerAuth: [] +paths: + /health: + get: + security: [] + summary: Compatibility health check + responses: + "200": { $ref: "#/components/responses/HealthUp" } + /health/live: + get: + security: [] + summary: Process liveness + responses: + "200": { $ref: "#/components/responses/HealthUp" } + /health/ready: + get: + security: [] + summary: Database and migration readiness + responses: + "200": { $ref: "#/components/responses/HealthUp" } + "503": + description: Database is unavailable or migrations failed. + /v1/auth/apple: + post: + security: [] + summary: Exchange Sign in with Apple credentials for an OSG session + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AppleSignInRequest" } + responses: + "200": + description: Authenticated session + content: + application/json: + schema: { $ref: "#/components/schemas/SessionTokenEnvelope" } + default: { $ref: "#/components/responses/Error" } + /v1/auth/refresh: + post: + security: [] + summary: Rotate a refresh token + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [refreshToken] + properties: + refreshToken: { type: string, minLength: 32 } + responses: + "200": + description: Rotated session + content: + application/json: + schema: { $ref: "#/components/schemas/SessionTokenEnvelope" } + default: { $ref: "#/components/responses/Error" } + /v1/auth/logout: + post: + summary: Revoke the current session family + responses: + "204": { description: Session revoked } + default: { $ref: "#/components/responses/Error" } + /v1/account: + get: + summary: Return the minimal account profile + responses: + "200": + description: Account profile + content: + application/json: + schema: { $ref: "#/components/schemas/AccountEnvelope" } + default: { $ref: "#/components/responses/Error" } + delete: + summary: Reauthenticate with Apple, delete the account, and revoke authorization + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DeleteAccountRequest" } + responses: + "204": { description: Account deleted } + default: { $ref: "#/components/responses/Error" } + /v1/apple/events: + post: + security: [] + summary: Receive a signed Apple server-to-server account event + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [payload] + properties: + payload: { type: string } + responses: + "204": { description: Event accepted } + default: { $ref: "#/components/responses/Error" } + /v1/credits/balance: + get: + summary: Return available integer credits + responses: + "200": + description: Credit account + content: + application/json: + schema: { $ref: "#/components/schemas/CreditAccount" } + default: { $ref: "#/components/responses/Error" } + /v1/credits/ledger: + get: + summary: Return immutable credit history + parameters: + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Ledger entries + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/LedgerEntry" } + default: { $ref: "#/components/responses/Error" } + /v1/credits/rates: + get: + summary: Return effective managed-provider rate cards + responses: + "200": + description: Effective rate cards + content: + application/json: + schema: + type: array + items: { type: object, additionalProperties: true } + default: { $ref: "#/components/responses/Error" } + /v1/referrals: + get: + summary: List invitees without exposing their Apple identity + parameters: + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Referral bindings + content: + application/json: + schema: + type: array + items: { type: object, additionalProperties: true } + default: { $ref: "#/components/responses/Error" } + /v1/referrals/me: + get: + summary: Return the current referral code and binding + responses: + "200": + description: Referral profile + content: + application/json: + schema: { type: object, additionalProperties: true } + default: { $ref: "#/components/responses/Error" } + /v1/referrals/code: + post: + summary: Idempotently create the current campaign invite code + responses: + "200": + description: Invite code + content: + application/json: + schema: { $ref: "#/components/schemas/ReferralCode" } + default: { $ref: "#/components/responses/Error" } + /v1/referrals/redeem: + post: + summary: Bind the account to an inviter during the eligibility window + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RedeemReferralRequest" } + responses: + "200": { description: Referral binding } + default: { $ref: "#/components/responses/Error" } + /v1/referrals/bind: + post: + deprecated: true + summary: Compatibility alias for referral redemption + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RedeemReferralRequest" } + responses: + "200": { description: Referral binding } + default: { $ref: "#/components/responses/Error" } + /v1/referrals/campaigns: + get: + summary: Return active referral campaigns + responses: + "200": { description: Active campaigns } + default: { $ref: "#/components/responses/Error" } + /v1/integrity/challenges: + post: + security: [] + summary: Issue a single-use App Attest challenge + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AppAttestChallengeRequest" } + responses: + "201": + description: Challenge issued + content: + application/json: + schema: { $ref: "#/components/schemas/AppAttestChallengeResponse" } + default: { $ref: "#/components/responses/Error" } + /v1/integrity/attest: + post: + security: [] + summary: Register a validated App Attest key + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AppAttestationRequest" } + responses: + "204": { description: Key registered } + default: { $ref: "#/components/responses/Error" } + /v1/integrity/assert: + post: + security: [] + summary: Validate a challenge-only App Attest assertion + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AppAssertionRequest" } + responses: + "200": { description: Assertion counter advanced } + default: { $ref: "#/components/responses/Error" } + /v1/gateway/catalog: + get: + summary: Return configured managed-provider capabilities + responses: + "200": { description: Provider catalog } + default: { $ref: "#/components/responses/Error" } + /v1/gateway/grants: + post: + summary: Create an idempotent managed-provider grant + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CreateGatewayGrantRequest" } + responses: + "201": + description: Gateway grant and rotating credentials + content: + application/json: + schema: { $ref: "#/components/schemas/GatewayGrantTokens" } + default: { $ref: "#/components/responses/GatewayError" } + /v1/gateway/grants/refresh: + post: + security: [] + summary: Rotate a gateway refresh token + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RefreshGatewayGrantRequest" } + responses: + "200": + description: Rotated gateway credentials + content: + application/json: + schema: { $ref: "#/components/schemas/GatewayGrantTokens" } + default: { $ref: "#/components/responses/GatewayError" } + /v1/gateway/grants/{grantId}: + delete: + summary: Revoke a gateway grant + parameters: + - name: grantId + in: path + required: true + schema: { type: string, format: uuid } + responses: + "204": { description: Gateway grant revoked } + default: { $ref: "#/components/responses/GatewayError" } + /v1/gateway/llm/{capability}: + post: + summary: Run a metered polish, AI, or agent request + parameters: + - $ref: "#/components/parameters/RequestId" + - name: capability + in: path + required: true + schema: { type: string, enum: [polish, ai, agent] } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TextGatewayRequest" } + responses: + "200": + description: JSON response, or SSE when stream is true + default: { $ref: "#/components/responses/GatewayError" } + /v1/gateway/asr: + post: + summary: Run buffered metered ASR + parameters: + - $ref: "#/components/parameters/RequestId" + - { name: X-Audio-Duration-Ms, in: header, required: true, schema: { type: integer, minimum: 1, maximum: 600000 } } + - { name: X-Audio-Format, in: header, schema: { type: string, enum: [pcm, wav, ogg, mp3], default: pcm } } + - { name: X-Audio-Codec, in: header, schema: { type: string, enum: [raw, opus], default: raw } } + requestBody: + required: true + content: + application/octet-stream: + schema: { type: string, contentEncoding: binary } + responses: + "200": { description: Newline-delimited Volcengine ASR result frames } + default: { $ref: "#/components/responses/GatewayError" } + /v1/gateway/asr/sessions: + post: + summary: Reserve credits and create a one-shot streaming ASR session + parameters: + - $ref: "#/components/parameters/RequestId" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CreateAsrSessionRequest" } + responses: + "201": + description: Session created + content: + application/json: + schema: { $ref: "#/components/schemas/CreateAsrSessionResponse" } + default: { $ref: "#/components/responses/GatewayError" } + /v1/gateway/asr/sessions/{sessionId}/stream: + get: + summary: Upgrade to WebSocket and stream binary audio frames + description: | + Send authenticated binary audio frames, then the exact text frame + `{"type":"end"}`. The server forwards binary provider-result frames and + closes the one-shot session after settlement. + parameters: + - name: sessionId + in: path + required: true + schema: { type: string, format: uuid } + responses: + "101": { description: WebSocket upgrade } + default: { $ref: "#/components/responses/GatewayError" } + /.well-known/apple-app-site-association: + get: + servers: + - url: https://osglab.com + security: [] + summary: Return the Apple Universal Links association document + responses: + "200": + description: AASA JSON without a redirect + headers: + Cache-Control: + schema: { type: string, const: "no-store, max-age=0" } + content: + application/json: + schema: { $ref: "#/components/schemas/AppleAppSiteAssociation" } + /apple-app-site-association: + get: + servers: + - url: https://osglab.com + security: [] + summary: Return the root compatibility AASA document + responses: + "200": + description: AASA JSON without a redirect + headers: + Cache-Control: + schema: { type: string, const: "no-store, max-age=0" } + content: + application/json: + schema: { $ref: "#/components/schemas/AppleAppSiteAssociation" } + /i/{code}: + get: + servers: + - url: https://osglab.com + security: [] + summary: Privacy-preserving invitation landing page + parameters: + - name: code + in: path + required: true + schema: { type: string, pattern: "^[A-Za-z0-9_-]{22}$" } + responses: + "200": { description: Bilingual HTML landing page } + "404": { description: Invalid, unknown, or expired invitation } + "503": { description: Invitation lookup is temporarily unavailable } +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + Limit: + name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 50 } + RequestId: + name: X-Request-ID + in: header + required: true + schema: { type: string, pattern: "^[A-Za-z0-9_-]{8,64}$" } + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: { type: string, minLength: 1, maxLength: 255 } + responses: + HealthUp: + description: Service is healthy + content: + application/json: + schema: + type: object + required: [status] + properties: + status: { type: string, const: UP } + Error: + description: API error + content: + application/json: + schema: { $ref: "#/components/schemas/ApiErrorEnvelope" } + GatewayError: + description: Managed-provider error + content: + application/json: + schema: { $ref: "#/components/schemas/GatewayError" } + schemas: + AppleSignInRequest: + type: object + additionalProperties: false + required: [identityToken, authorizationCode, nonce] + properties: + identityToken: { type: string } + authorizationCode: { type: string } + nonce: { type: string } + deviceCheckToken: + type: ["string", "null"] + description: Ephemeral DeviceCheck token; never persisted in plaintext. + appAttest: + oneOf: + - $ref: "#/components/schemas/AppAttestAssertion" + - type: "null" + AppAttestAssertion: + type: object + additionalProperties: false + required: [keyId, challengeId, challenge, assertion] + properties: + keyId: { type: string, maxLength: 128 } + challengeId: { type: string, format: uuid } + challenge: { type: string, description: Base64URL challenge returned by the server } + assertion: { type: string, contentEncoding: base64 } + SessionTokenResponse: + type: object + required: [accountId, tokenType, accessToken, accessTokenExpiresAtEpochSeconds, refreshToken, refreshTokenExpiresAtEpochSeconds] + properties: + accountId: { type: string, format: uuid } + tokenType: { type: string, const: Bearer } + accessToken: { type: string } + accessTokenExpiresAtEpochSeconds: { type: integer, format: int64 } + refreshToken: { type: string } + refreshTokenExpiresAtEpochSeconds: { type: integer, format: int64 } + SessionTokenEnvelope: + type: object + additionalProperties: false + required: [data] + properties: + data: { $ref: "#/components/schemas/SessionTokenResponse" } + Account: + type: object + required: [id, createdAtEpochSeconds] + properties: + id: { type: string, format: uuid } + createdAtEpochSeconds: { type: integer, format: int64 } + AccountEnvelope: + type: object + additionalProperties: false + required: [data] + properties: + data: { $ref: "#/components/schemas/Account" } + DeleteAccountRequest: + type: object + additionalProperties: false + required: [identityToken, authorizationCode, nonce] + properties: + identityToken: { type: string, maxLength: 16384 } + authorizationCode: { type: string, maxLength: 4096 } + nonce: { type: string, maxLength: 512 } + CreditAccount: + type: object + additionalProperties: true + required: [userId, balance] + properties: + userId: { type: string, format: uuid } + balance: { type: integer, format: int64, minimum: 0 } + LedgerEntry: + type: object + additionalProperties: true + required: [id, amountDelta, balanceAfter] + properties: + id: { type: string, format: uuid } + amountDelta: { type: integer, format: int64 } + balanceAfter: { type: integer, format: int64, minimum: 0 } + AppleAppSiteAssociation: + type: object + additionalProperties: false + required: [applinks] + properties: + applinks: + type: object + additionalProperties: false + required: [details] + properties: + details: + type: array + items: + type: object + required: [appIDs, components] + properties: + appIDs: + type: array + items: { type: string } + components: + type: array + items: { type: object, additionalProperties: true } + ReferralCode: + type: object + additionalProperties: true + properties: + code: { type: string, pattern: "^[A-Za-z0-9_-]{22}$" } + RedeemReferralRequest: + type: object + additionalProperties: false + required: [code] + properties: + code: { type: string, pattern: "^[A-Za-z0-9_-]{22}$" } + AppAttestChallengeRequest: + type: object + additionalProperties: false + required: [purpose, keyId] + properties: + purpose: { type: string, enum: [attestation, assertion] } + keyId: { type: string, maxLength: 128 } + AppAttestChallengeResponse: + type: object + required: [challengeId, challenge, expiresAtEpochSeconds] + properties: + challengeId: { type: string, format: uuid } + challenge: { type: string } + expiresAtEpochSeconds: { type: integer, format: int64 } + AppAttestationRequest: + type: object + additionalProperties: false + required: [challengeId, challenge, keyId, attestationObject] + properties: + challengeId: { type: string, format: uuid } + challenge: { type: string } + keyId: { type: string } + attestationObject: { type: string, contentEncoding: base64 } + AppAssertionRequest: + type: object + additionalProperties: false + required: [challengeId, challenge, keyId, assertion, clientDataHash] + properties: + challengeId: { type: string, format: uuid } + challenge: { type: string } + keyId: { type: string } + assertion: { type: string, contentEncoding: base64 } + clientDataHash: { type: string, description: Base64URL SHA-256 of the echoed challenge } + TextGatewayRequest: + type: object + additionalProperties: false + required: [input] + properties: + input: { type: string, minLength: 1, maxLength: 32000 } + context: { type: ["string", "null"], maxLength: 32000 } + maxOutputTokens: { type: integer, minimum: 1, maximum: 4096, default: 512 } + temperature: { type: number, minimum: 0, maximum: 1, default: 0.2 } + stream: { type: boolean, default: false } + CreateGatewayGrantRequest: + type: object + additionalProperties: false + required: [scopes] + properties: + scopes: + type: array + uniqueItems: true + minItems: 1 + items: { type: string, enum: [polish, ai, agent, asr] } + lifetimeSeconds: { type: ["integer", "null"], format: int64, minimum: 1 } + RefreshGatewayGrantRequest: + type: object + additionalProperties: false + required: [refreshToken] + properties: + refreshToken: { type: string, minLength: 32 } + GatewayGrantTokens: + type: object + additionalProperties: false + required: [grantId, scopes, accessToken, accessExpiresAt, refreshToken, refreshExpiresAt] + properties: + grantId: { type: string, format: uuid } + scopes: + type: array + uniqueItems: true + items: { type: string, enum: [polish, ai, agent, asr] } + accessToken: { type: string } + accessExpiresAt: { type: string, format: date-time } + refreshToken: { type: string } + refreshExpiresAt: { type: string, format: date-time } + CreateAsrSessionRequest: + type: object + additionalProperties: false + required: [estimatedDurationMillis] + properties: + format: { type: string, enum: [pcm, wav, ogg, mp3], default: pcm } + codec: { type: string, enum: [raw, opus], default: raw } + sampleRate: { type: integer, const: 16000 } + bits: { type: integer, const: 16 } + channels: { type: integer, minimum: 1, maximum: 2, default: 1 } + language: { type: ["string", "null"], maxLength: 32 } + estimatedDurationMillis: { type: integer, minimum: 1, maximum: 600000 } + CreateAsrSessionResponse: + type: object + required: [sessionId, websocketPath, maxFrameBytes, idleTimeoutMillis] + properties: + sessionId: { type: string, format: uuid } + websocketPath: { type: string } + maxFrameBytes: { type: integer } + idleTimeoutMillis: { type: integer, format: int64 } + ApiError: + type: object + additionalProperties: false + required: [code, message] + properties: + code: { type: string } + message: { type: string } + ApiErrorEnvelope: + type: object + additionalProperties: false + required: [error] + properties: + error: { $ref: "#/components/schemas/ApiError" } + GatewayError: + type: object + required: [code, message, requestId] + properties: + code: { type: string } + message: { type: string } + requestId: { type: string } diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..cdb7c5f --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.configuration-cache=true +org.gradle.caching=true +org.gradle.parallel=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx1g diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9125741 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.6.1-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 +networkTimeout=30000 +retries=2 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..6a276d6 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,10 @@ +rootProject.name = "OSGAccountServer" + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} diff --git a/src/main/kotlin/com/osglab/account/Application.kt b/src/main/kotlin/com/osglab/account/Application.kt new file mode 100644 index 0000000..ad033e7 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/Application.kt @@ -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().database + val sessionAuthenticator = koin.get() + 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().processPending() + } catch (exception: CancellationException) { + throw exception + } catch (_: Exception) { + // Durable outbox state is retried; never log sensitive token material. + } + try { + koin.get().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().close() + koin.get().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(), + gatewayIdentity = koin.get(), + 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().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 { + RemoteAppleJwksProvider(get(), config.apple.jwksUrl) + } + single { AppleIdentityTokenVerifier(config.apple, get()) } + single { createAppleTokenClient(get(), config.apple) } + single { + createDeviceCheckClient(get(), config.apple, config.integrity.appleEnvironment) + ?: UnavailableAppleDeviceCheckClient() + } + single { RemoteDeviceCheckVerifier(get()) } + single { ExposedAppAttestRepository(get()) } + single { + LibraryAppAttestCrypto( + config = config.integrity, + certificateValidator = BundledAppleAppAttestTrust.validator(), + ) + } + single { AppAttestService(get(), get(), config.integrity) } + single { get() } + single { IntegrityService(config.integrity, get(), get()) } + single { ExposedDeviceCheckTrialClaimRepository(get()) } + single { MysqlDeviceCheckTrialMutex(get()) } + + single { ExposedAuthRepository(get()) } + single { SessionAccessAuthenticator(get(), get()) } + single { ExposedAccountRepository(get(), get()) } + single { ExposedAppleEventRepository(get(), get(), config.antiAbuse) } + single { AppleEventVerifier(config.apple, get()) } + single { AppleEventService(get(), get()) } + + single { + ExposedBillingTransactionRunner(get()) + } + single { + CreditService( + transactions = get(), + referralRewards = ReferralRewardConfig( + inviterCredits = config.credits.referralInviter, + inviteeCredits = config.credits.referralInvitee, + ), + ) + } + single { + TrialCreditGranter { accountId -> + get().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(), + ) + } + single { ExposedGatewayRepository(get()) } + single { get() } + single { get() } + single { get() } + single { + AccountProvisioner { accountId, deviceCheckToken -> + val granted = get().claimAndGrant(accountId, deviceCheckToken) + if (deviceCheckToken != null && !granted) { + get().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 { + AppleAccountReauthenticator( + identityVerifier = get(), + appleTokenClient = get(), + identityFingerprint = get(), + ) + } + single { + AccountService( + repository = get(), + fieldEncryptor = get(), + antiAbuseConfig = config.antiAbuse, + revocationProcessor = get(), + reauthenticator = get(), + ) + } + + single { + UserRegistrationTimeProvider { accountId -> + get().findById(accountId)?.createdAt + } + } + single { + ReferralRiskProvider { accountId -> + get().findById(accountId)?.let { + ReferralRiskIdentity(it.identityFingerprint, it.antiAbuseRestricted) + } + } + } + single { + ReferralService( + transactions = get(), + registrationTimeProvider = get(), + riskProvider = get(), + bindingWindow = Duration.ofDays(config.credits.referralBindingDays), + ) + } + single { + val transactions = get() + 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 { get() } + single { get() } + 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 { GatewayBearerIdentity(get()) } + single { + 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 = + 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") diff --git a/src/main/kotlin/com/osglab/account/common/api/ApiContract.kt b/src/main/kotlin/com/osglab/account/common/api/ApiContract.kt new file mode 100644 index 0000000..295ffb3 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/common/api/ApiContract.kt @@ -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(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 { call, cause -> + call.attributes.put(API_ERROR_HANDLED, true) + call.respond( + cause.status, + ApiErrorResponse(ApiError(cause.code, cause.message)), + ) + } + exception { call, _ -> + call.respond( + HttpStatusCode.BadRequest, + ApiErrorResponse(ApiError("invalid_request", "Request body is invalid")), + ) + } + exception { call, _ -> + call.respond( + HttpStatusCode.InternalServerError, + ApiErrorResponse(ApiError("internal_error", "An internal error occurred")), + ) + } + } +} + +private val API_ERROR_HANDLED = AttributeKey("api-error-handled") diff --git a/src/main/kotlin/com/osglab/account/common/errors/ApiErrors.kt b/src/main/kotlin/com/osglab/account/common/errors/ApiErrors.kt new file mode 100644 index 0000000..9f20df4 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/common/errors/ApiErrors.kt @@ -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 { call, cause -> + call.respond( + cause.status, + ApiErrorResponse(ApiError(cause.code, cause.message)), + ) + } + exception { call, _ -> + call.respond( + HttpStatusCode.InternalServerError, + ApiErrorResponse(ApiError("internal_error", "An internal error occurred")), + ) + } + } +} diff --git a/src/main/kotlin/com/osglab/account/common/security/FieldEncryption.kt b/src/main/kotlin/com/osglab/account/common/security/FieldEncryption.kt new file mode 100644 index 0000000..d12143a --- /dev/null +++ b/src/main/kotlin/com/osglab/account/common/security/FieldEncryption.kt @@ -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" diff --git a/src/main/kotlin/com/osglab/account/common/security/IdentityFingerprint.kt b/src/main/kotlin/com/osglab/account/common/security/IdentityFingerprint.kt new file mode 100644 index 0000000..b629f00 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/common/security/IdentityFingerprint.kt @@ -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 + } +} diff --git a/src/main/kotlin/com/osglab/account/common/security/SessionJwt.kt b/src/main/kotlin/com/osglab/account/common/security/SessionJwt.kt new file mode 100644 index 0000000..c15dfc8 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/common/security/SessionJwt.kt @@ -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 diff --git a/src/main/kotlin/com/osglab/account/common/security/TokenSecurity.kt b/src/main/kotlin/com/osglab/account/common/security/TokenSecurity.kt new file mode 100644 index 0000000..2dc1027 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/common/security/TokenSecurity.kt @@ -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) + } +} diff --git a/src/main/kotlin/com/osglab/account/config/AppConfig.kt b/src/main/kotlin/com/osglab/account/config/AppConfig.kt new file mode 100644 index 0000000..86323f8 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/config/AppConfig.kt @@ -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]*") diff --git a/src/main/kotlin/com/osglab/account/config/DatabaseFactory.kt b/src/main/kotlin/com/osglab/account/config/DatabaseFactory.kt new file mode 100644 index 0000000..7a3a3a5 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/config/DatabaseFactory.kt @@ -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 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 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" + }, + ) +} diff --git a/src/main/kotlin/com/osglab/account/config/HttpPlugins.kt b/src/main/kotlin/com/osglab/account/config/HttpPlugins.kt new file mode 100644 index 0000000..4a47d95 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/config/HttpPlugins.kt @@ -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" diff --git a/src/main/kotlin/com/osglab/account/features/account/AccountRepository.kt b/src/main/kotlin/com/osglab/account/features/account/AccountRepository.kt new file mode 100644 index 0000000..faf4294 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/account/AccountRepository.kt @@ -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 + 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 = 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 + } +} diff --git a/src/main/kotlin/com/osglab/account/features/account/AccountRoutes.kt b/src/main/kotlin/com/osglab/account/features/account/AccountRoutes.kt new file mode 100644 index 0000000..c386f88 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/account/AccountRoutes.kt @@ -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() + ?: 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() + ?: throw UnauthorizedException() + accountService.delete( + principal.userId, + call.receive().toProof(), + ) + call.respond(HttpStatusCode.NoContent) + } + } + } + } + } +} + +fun Route.accountRoutes(accountService: AccountService) = + AccountRoutes(accountService).register(this) diff --git a/src/main/kotlin/com/osglab/account/features/account/AccountService.kt b/src/main/kotlin/com/osglab/account/features/account/AccountService.kt new file mode 100644 index 0000000..4b5ea3e --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/account/AccountService.kt @@ -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" diff --git a/src/main/kotlin/com/osglab/account/features/appleevents/AppleEventRepository.kt b/src/main/kotlin/com/osglab/account/features/appleevents/AppleEventRepository.kt new file mode 100644 index 0000000..895fc22 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/appleevents/AppleEventRepository.kt @@ -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 diff --git a/src/main/kotlin/com/osglab/account/features/appleevents/AppleEventRoutes.kt b/src/main/kotlin/com/osglab/account/features/appleevents/AppleEventRoutes.kt new file mode 100644 index 0000000..fae29ee --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/appleevents/AppleEventRoutes.kt @@ -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().payload) + call.respond(HttpStatusCode.NoContent) + } + } + } +} + +fun Route.appleEventRoutes(service: AppleEventService) = + AppleEventRoutes(service).register(this) diff --git a/src/main/kotlin/com/osglab/account/features/appleevents/AppleEventVerifier.kt b/src/main/kotlin/com/osglab/account/features/appleevents/AppleEventVerifier.kt new file mode 100644 index 0000000..b95dc29 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/appleevents/AppleEventVerifier.kt @@ -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) diff --git a/src/main/kotlin/com/osglab/account/features/auth/AppleIdentityTokenVerifier.kt b/src/main/kotlin/com/osglab/account/features/auth/AppleIdentityTokenVerifier.kt new file mode 100644 index 0000000..59d9bf0 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/auth/AppleIdentityTokenVerifier.kt @@ -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) diff --git a/src/main/kotlin/com/osglab/account/features/auth/AppleTokenClient.kt b/src/main/kotlin/com/osglab/account/features/auth/AppleTokenClient.kt new file mode 100644 index 0000000..6a05f14 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/auth/AppleTokenClient.kt @@ -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(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) diff --git a/src/main/kotlin/com/osglab/account/features/auth/AuthRepository.kt b/src/main/kotlin/com/osglab/account/features/auth/AuthRepository.kt new file mode 100644 index 0000000..95dfb9e --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/auth/AuthRepository.kt @@ -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 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 diff --git a/src/main/kotlin/com/osglab/account/features/auth/AuthRoutes.kt b/src/main/kotlin/com/osglab/account/features/auth/AuthRoutes.kt new file mode 100644 index 0000000..fffdae8 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/auth/AuthRoutes.kt @@ -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() + 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() + call.respond( + ApiResponse(data = sessionService.refresh(request.refreshToken).toResponse()), + ) + } + authenticate(SESSION_AUTH_NAME) { + post("/logout") { + val principal = call.principal() + ?: 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, +) diff --git a/src/main/kotlin/com/osglab/account/features/auth/SessionAccessAuthenticator.kt b/src/main/kotlin/com/osglab/account/features/auth/SessionAccessAuthenticator.kt new file mode 100644 index 0000000..5aec5e2 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/auth/SessionAccessAuthenticator.kt @@ -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()) + } + } +} diff --git a/src/main/kotlin/com/osglab/account/features/auth/SessionService.kt b/src/main/kotlin/com/osglab/account/features/auth/SessionService.kt new file mode 100644 index 0000000..f7ac3b1 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/auth/SessionService.kt @@ -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" diff --git a/src/main/kotlin/com/osglab/account/features/credits/domain/CreditDomain.kt b/src/main/kotlin/com/osglab/account/features/credits/domain/CreditDomain.kt new file mode 100644 index 0000000..95005e4 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/credits/domain/CreditDomain.kt @@ -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" +} diff --git a/src/main/kotlin/com/osglab/account/features/credits/models/CreditDtos.kt b/src/main/kotlin/com/osglab/account/features/credits/models/CreditDtos.kt new file mode 100644 index 0000000..92ccbfb --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/credits/models/CreditDtos.kt @@ -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, +) diff --git a/src/main/kotlin/com/osglab/account/features/credits/repositories/BillingRepositories.kt b/src/main/kotlin/com/osglab/account/features/credits/repositories/BillingRepositories.kt new file mode 100644 index 0000000..3faa50d --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/credits/repositories/BillingRepositories.kt @@ -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 + + 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 +} + +interface BillingUnitOfWork { + val credits: CreditsRepository + val referrals: ReferralsRepository +} + +interface BillingTransactionRunner { + suspend fun inTransaction(block: (BillingUnitOfWork) -> T): T +} diff --git a/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt b/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt new file mode 100644 index 0000000..6305a4e --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt @@ -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("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("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("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("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("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("reward_status", 24) + + override val primaryKey = PrimaryKey(id) +} + +class ExposedBillingTransactionRunner( + private val database: Database, +) : BillingTransactionRunner { + override suspend fun 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 = + 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 = + 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 setUsage( + statement: org.jetbrains.exposed.v1.core.statements.UpdateBuilder, + 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 = + 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 = + 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], +) diff --git a/src/main/kotlin/com/osglab/account/features/credits/routes/CreditRoutes.kt b/src/main/kotlin/com/osglab/account/features/credits/routes/CreditRoutes.kt new file mode 100644 index 0000000..d8924ef --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/credits/routes/CreditRoutes.kt @@ -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()?.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())) + } +} diff --git a/src/main/kotlin/com/osglab/account/features/credits/services/CreditService.kt b/src/main/kotlin/com/osglab/account/features/credits/services/CreditService.kt new file mode 100644 index 0000000..b005c29 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/credits/services/CreditService.kt @@ -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 + + suspend fun listLedger(userId: UUID, limit: Int = 50): List + + 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 = + transactions.inTransaction { it.credits.listEffectiveRates(clock.instant()) } + + override suspend fun listLedger(userId: UUID, limit: Int): List { + 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, + 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, + ) +} diff --git a/src/main/kotlin/com/osglab/account/features/gateway/GatewaySettings.kt b/src/main/kotlin/com/osglab/account/features/gateway/GatewaySettings.kt new file mode 100644 index 0000000..6af0cfd --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/GatewaySettings.kt @@ -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, +) diff --git a/src/main/kotlin/com/osglab/account/features/gateway/adapters/GatewayAdapters.kt b/src/main/kotlin/com/osglab/account/features/gateway/adapters/GatewayAdapters.kt new file mode 100644 index 0000000..f66078d --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/adapters/GatewayAdapters.kt @@ -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 = 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" + } +} diff --git a/src/main/kotlin/com/osglab/account/features/gateway/agent/AgentModels.kt b/src/main/kotlin/com/osglab/account/features/gateway/agent/AgentModels.kt new file mode 100644 index 0000000..b8cefd2 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/agent/AgentModels.kt @@ -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, + val warnings: List = emptyList(), +) + +@Serializable +data class AgentStep( + val id: String, + val title: String, + val description: String, +) diff --git a/src/main/kotlin/com/osglab/account/features/gateway/asr/AsrStreamingGateway.kt b/src/main/kotlin/com/osglab/account/features/gateway/asr/AsrStreamingGateway.kt new file mode 100644 index 0000000..e850f2e --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/asr/AsrStreamingGateway.kt @@ -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() + private val userGates = ConcurrentHashMap() + + 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, + 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, + options: AsrGatewayOptions, + ): Flow = 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 = 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") diff --git a/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt b/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt new file mode 100644 index 0000000..d0c2965 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt @@ -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 = 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, + val streaming: Boolean, + val usageMeter: UsageMeter, +) + +@Serializable +data class GatewayCatalogResponse( + val providers: List, +) + +@Serializable +data class GatewayErrorResponse( + val code: String, + val message: String, + val requestId: String, +) + +@Serializable +data class CreateGatewayGrantRequest( + val scopes: Set, + val lifetimeSeconds: Long? = null, +) + +@Serializable +data class RefreshGatewayGrantRequest( + val refreshToken: String, +) + +@Serializable +data class GatewayGrantTokens( + val grantId: String, + val scopes: Set, + val accessToken: String, + val accessExpiresAt: String, + val refreshToken: String, + val refreshExpiresAt: String, +) + +data class GatewayGrant( + val id: String, + val accountId: String, + val scopes: Set, + val expiresAt: Instant, + val revokedAt: Instant? = null, +) diff --git a/src/main/kotlin/com/osglab/account/features/gateway/polish/DeepSeekGateway.kt b/src/main/kotlin/com/osglab/account/features/gateway/polish/DeepSeekGateway.kt new file mode 100644 index 0000000..857c76c --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/polish/DeepSeekGateway.kt @@ -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 diff --git a/src/main/kotlin/com/osglab/account/features/gateway/ports/GatewayPorts.kt b/src/main/kotlin/com/osglab/account/features/gateway/ports/GatewayPorts.kt new file mode 100644 index 0000000..8e9d995 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/ports/GatewayPorts.kt @@ -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, + 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, + 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 + + suspend fun markRefunded(accountId: String, requestId: String) = Unit + + suspend fun findRefundPending(limit: Int): List = emptyList() +} diff --git a/src/main/kotlin/com/osglab/account/features/gateway/providers/GatewayProvider.kt b/src/main/kotlin/com/osglab/account/features/gateway/providers/GatewayProvider.kt new file mode 100644 index 0000000..7a0e6ed --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/providers/GatewayProvider.kt @@ -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, +) { + 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 = + 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) diff --git a/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekProvider.kt b/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekProvider.kt new file mode 100644 index 0000000..1b0073e --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekProvider.kt @@ -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().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().readBounded() } + throw DeepSeekProviderException("DeepSeek returned an unexpected content type") + } + + if (request.stream) { + forwardSse(response.body(), request, output) + } else { + forwardJson(response.body().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(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 { + 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, + @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?, +) diff --git a/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/SaucV3Protocol.kt b/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/SaucV3Protocol.kt new file mode 100644 index 0000000..54aa3e9 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/SaucV3Protocol.kt @@ -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) diff --git a/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/VolcengineAsrProvider.kt b/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/VolcengineAsrProvider.kt new file mode 100644 index 0000000..8eb88ed --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/VolcengineAsrProvider.kt @@ -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, + 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, + 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 + } +} diff --git a/src/main/kotlin/com/osglab/account/features/gateway/repositories/ExposedGatewayRepository.kt b/src/main/kotlin/com/osglab/account/features/gateway/repositories/ExposedGatewayRepository.kt new file mode 100644 index 0000000..0ba02c6 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/repositories/ExposedGatewayRepository.kt @@ -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, + 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 { + 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) diff --git a/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt b/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt new file mode 100644 index 0000000..42034e2 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt @@ -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( + 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( + 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( + 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( + 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 diff --git a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayGrantService.kt b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayGrantService.kt new file mode 100644 index 0000000..0750dbe --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayGrantService.kt @@ -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") diff --git a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayService.kt b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayService.kt new file mode 100644 index 0000000..e2260bd --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayService.kt @@ -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()}") diff --git a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayUsageEstimator.kt b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayUsageEstimator.kt new file mode 100644 index 0000000..6826559 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayUsageEstimator.kt @@ -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 +} diff --git a/src/main/kotlin/com/osglab/account/features/health/HealthRoutes.kt b/src/main/kotlin/com/osglab/account/features/health/HealthRoutes.kt new file mode 100644 index 0000000..b358e9d --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/health/HealthRoutes.kt @@ -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) diff --git a/src/main/kotlin/com/osglab/account/features/integrity/AppAttest.kt b/src/main/kotlin/com/osglab/account/features/integrity/AppAttest.kt new file mode 100644 index 0000000..279a4fe --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/integrity/AppAttest.kt @@ -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("purpose", 16) + val challengeHash = char("challenge_hash", 64) + val status = enumerationByName("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("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() + 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() + 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() + 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() diff --git a/src/main/kotlin/com/osglab/account/features/integrity/AppAttestCrypto.kt b/src/main/kotlin/com/osglab/account/features/integrity/AppAttestCrypto.kt new file mode 100644 index 0000000..7980f69 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/integrity/AppAttestCrypto.kt @@ -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): ValidatedAppAttestCertificate +} + +data class ValidatedAppAttestCertificate( + val publicKey: ECPublicKey, + val nonce: ByteArray, +) + +class PkixAppAttestCertificateValidator( + appleRoots: Collection, + private val clock: Clock = Clock.systemUTC(), +) : AppAttestCertificateValidator { + private val roots: Set = 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, + ): 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 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) diff --git a/src/main/kotlin/com/osglab/account/features/integrity/DeviceCheck.kt b/src/main/kotlin/com/osglab/account/features/integrity/DeviceCheck.kt new file mode 100644 index 0000000..5c2ac4d --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/integrity/DeviceCheck.kt @@ -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(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 withLock(block: suspend () -> T): T +} + +class MysqlDeviceCheckTrialMutex( + private val databaseFactory: DatabaseFactory, +) : DeviceCheckTrialMutex { + override suspend fun 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 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("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, +) diff --git a/src/main/kotlin/com/osglab/account/features/integrity/IntegrityPorts.kt b/src/main/kotlin/com/osglab/account/features/integrity/IntegrityPorts.kt new file mode 100644 index 0000000..601f264 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/integrity/IntegrityPorts.kt @@ -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 + } +} diff --git a/src/main/kotlin/com/osglab/account/features/integrity/IntegrityVerification.kt b/src/main/kotlin/com/osglab/account/features/integrity/IntegrityVerification.kt new file mode 100644 index 0000000..74a15c5 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/integrity/IntegrityVerification.kt @@ -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) +} diff --git a/src/main/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutes.kt b/src/main/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutes.kt new file mode 100644 index 0000000..97d7984 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutes.kt @@ -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() + val teamId = requireNotNull(appConfig.apple.teamId) { + "APPLE_TEAM_ID is required to publish the AASA document" + } + configureInviteWebRoutes( + referralLookup = koin.get(), + 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 { + 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)) +} diff --git a/src/main/kotlin/com/osglab/account/features/referrals/domain/ReferralDomain.kt b/src/main/kotlin/com/osglab/account/features/referrals/domain/ReferralDomain.kt new file mode 100644 index 0000000..93559f0 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/referrals/domain/ReferralDomain.kt @@ -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") diff --git a/src/main/kotlin/com/osglab/account/features/referrals/models/ReferralDtos.kt b/src/main/kotlin/com/osglab/account/features/referrals/models/ReferralDtos.kt new file mode 100644 index 0000000..ea0a95e --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/referrals/models/ReferralDtos.kt @@ -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, +) diff --git a/src/main/kotlin/com/osglab/account/features/referrals/repositories/ReferralsRepository.kt b/src/main/kotlin/com/osglab/account/features/referrals/repositories/ReferralsRepository.kt new file mode 100644 index 0000000..0c966c5 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/referrals/repositories/ReferralsRepository.kt @@ -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 + + fun lockCampaignBudget(campaignId: UUID): ReferralCampaignBudget + + fun updateCampaignBudget(budget: ReferralCampaignBudget) + + fun findBinding(inviteeUserId: UUID): ReferralBinding? + + fun listBindingsByInviter(inviterUserId: UUID, limit: Int): List + + fun lockBinding(inviteeUserId: UUID): ReferralBinding? + + fun insertBindingIfAbsent(binding: ReferralBinding): Boolean + + fun markRewarded( + bindingId: UUID, + settlementId: UUID, + rewardedAt: Instant, + ) + + fun markRewardIneligible(bindingId: UUID) +} diff --git a/src/main/kotlin/com/osglab/account/features/referrals/routes/ReferralRoutes.kt b/src/main/kotlin/com/osglab/account/features/referrals/routes/ReferralRoutes.kt new file mode 100644 index 0000000..fded1d1 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/referrals/routes/ReferralRoutes.kt @@ -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() + 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() + 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())) + } +} diff --git a/src/main/kotlin/com/osglab/account/features/referrals/services/ReferralService.kt b/src/main/kotlin/com/osglab/account/features/referrals/services/ReferralService.kt new file mode 100644 index 0000000..0fe8b0c --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/referrals/services/ReferralService.kt @@ -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 + + suspend fun listInvited(userId: UUID, limit: Int = 50): List +} + +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 = + transactions.inTransaction { it.referrals.listActiveCampaigns(clock.instant()) } + + override suspend fun listInvited(userId: UUID, limit: Int): List { + 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 + } +} diff --git a/src/main/resources/apple/Apple_App_Attestation_Root_CA.pem b/src/main/resources/apple/Apple_App_Attestation_Root_CA.pem new file mode 100644 index 0000000..4cff227 --- /dev/null +++ b/src/main/resources/apple/Apple_App_Attestation_Root_CA.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw +JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK +QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa +Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv +biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y +bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh +NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au +Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw +CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn +53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV +oyFraWVIyd/dganmrduC1bmTBGwD +-----END CERTIFICATE----- diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..2d4ab4e --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,61 @@ +ktor: + application: + modules: + - com.osglab.account.ApplicationKt.module + deployment: + host: 0.0.0.0 + port: "$PORT:8080" + +app: + environment: "$APP_ENV:development" + publicBaseUrl: "$PUBLIC_BASE_URL:https://account.osglab.com" + inviteBaseUrl: "$INVITE_BASE_URL:https://osglab.com/i" + appStoreUrl: "$APP_STORE_URL:https://apps.apple.com/app/id0000000000" + database: + jdbcUrl: "$DATABASE_URL:jdbc:mysql://localhost:3306/osg_account?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true" + username: "$DATABASE_USER:osg_account" + password: "$DATABASE_PASSWORD" + migrationUsername: "$DATABASE_MIGRATION_USER:" + migrationPassword: "$DATABASE_MIGRATION_PASSWORD:" + maximumPoolSize: "$DATABASE_POOL_SIZE:10" + session: + issuer: "$JWT_ISSUER:https://account.osglab.com" + audience: "$JWT_AUDIENCE:osgkeyboard-ios" + secret: "$JWT_SECRET" + accessMinutes: "$ACCESS_TOKEN_MINUTES:15" + refreshDays: "$REFRESH_TOKEN_DAYS:30" + gatewayGrantDays: "$GATEWAY_GRANT_DAYS:30" + encryption: + keyBase64: "$FIELD_ENCRYPTION_KEY" + antiAbuse: + identityHmacKeyBase64: "$IDENTITY_HMAC_KEY" + tombstoneRetentionDays: "$IDENTITY_TOMBSTONE_RETENTION_DAYS:365" + apple: + teamId: "$APPLE_TEAM_ID:" + keyId: "$APPLE_KEY_ID:" + clientId: "$APPLE_CLIENT_ID:com.osgkeyboard.ios" + privateKeyPem: "$APPLE_PRIVATE_KEY_PEM:" + jwksUrl: "$APPLE_JWKS_URL:https://appleid.apple.com/auth/keys" + tokenUrl: "$APPLE_TOKEN_URL:https://appleid.apple.com/auth/token" + revokeUrl: "$APPLE_REVOKE_URL:https://appleid.apple.com/auth/revoke" + credits: + signupTrial: "$SIGNUP_TRIAL_CREDITS:1000" + referralInviter: "$REFERRAL_INVITER_CREDITS:3000" + referralInvitee: "$REFERRAL_INVITEE_CREDITS:3000" + referralBindingDays: "$REFERRAL_BINDING_DAYS:7" + providers: + volcengine: + endpoint: "$VOLCENGINE_ASR_ENDPOINT:wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async" + appId: "$VOLCENGINE_APP_ID:" + accessToken: "$VOLCENGINE_ACCESS_TOKEN:" + apiKey: "$VOLCENGINE_API_KEY:" + resourceId: "$VOLCENGINE_RESOURCE_ID:volc.seedasr.sauc.duration" + deepseek: + endpoint: "$DEEPSEEK_ENDPOINT:https://api.deepseek.com/v1" + apiKey: "$DEEPSEEK_API_KEY:" + model: "$DEEPSEEK_MODEL:deepseek-v4-flash" + integrity: + enforceDeviceCheck: "$ENFORCE_DEVICE_CHECK:false" + enforceAppAttest: "$ENFORCE_APP_ATTEST:false" + appleEnvironment: "$APPLE_INTEGRITY_ENVIRONMENT:development" + challengeLifetimeSeconds: "$APP_ATTEST_CHALLENGE_TTL_SECONDS:300" diff --git a/src/main/resources/db/migration/V1__identity_and_sessions.sql b/src/main/resources/db/migration/V1__identity_and_sessions.sql new file mode 100644 index 0000000..ea8e37b --- /dev/null +++ b/src/main/resources/db/migration/V1__identity_and_sessions.sql @@ -0,0 +1,47 @@ +CREATE TABLE accounts ( + id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + apple_sub VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_accounts_apple_sub (apple_sub) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE apple_credentials ( + account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + encrypted_refresh_token MEDIUMTEXT NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (account_id), + CONSTRAINT fk_apple_credentials_account + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE sessions ( + id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + family_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + refresh_token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + replaced_by_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + created_at TIMESTAMP(6) NOT NULL, + expires_at TIMESTAMP(6) NOT NULL, + revoked_at TIMESTAMP(6) NULL, + reuse_detected_at TIMESTAMP(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_sessions_refresh_token_hash (refresh_token_hash), + KEY ix_sessions_account_id (account_id), + KEY ix_sessions_family_id (family_id), + KEY ix_sessions_account_active (account_id, revoked_at, expires_at), + CONSTRAINT fk_sessions_account + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE, + CONSTRAINT fk_sessions_replaced_by + FOREIGN KEY (replaced_by_id) REFERENCES sessions (id) ON DELETE SET NULL, + CONSTRAINT chk_sessions_expiry CHECK (expires_at > created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE apple_event_receipts ( + event_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + event_type VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + received_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/src/main/resources/db/migration/V2__credits_and_referrals.sql b/src/main/resources/db/migration/V2__credits_and_referrals.sql new file mode 100644 index 0000000..56eb412 --- /dev/null +++ b/src/main/resources/db/migration/V2__credits_and_referrals.sql @@ -0,0 +1,352 @@ +CREATE TABLE credit_accounts ( + user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + balance BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (user_id), + CONSTRAINT chk_credit_accounts_non_negative CHECK (balance >= 0) +) ENGINE = InnoDB; + +CREATE TABLE credit_rate_versions ( + id CHAR(36) NOT NULL, + kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + provider VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + model VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + effective_from DATETIME(6) NOT NULL, + effective_until DATETIME(6) NULL, + asr_credits_numerator BIGINT NULL, + asr_millis_denominator BIGINT NULL, + input_credits_numerator BIGINT NULL, + input_tokens_denominator BIGINT NULL, + output_credits_numerator BIGINT NULL, + output_tokens_denominator BIGINT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uk_credit_rates_effective ( + kind, provider, model, effective_from + ), + INDEX idx_credit_rates_lookup (kind, provider, model, effective_from), + CONSTRAINT chk_credit_rates_interval + CHECK (effective_until IS NULL OR effective_until > effective_from), + CONSTRAINT chk_credit_rates_shape CHECK ( + ( + kind = 'ASR' + AND asr_credits_numerator > 0 + AND asr_millis_denominator > 0 + AND input_credits_numerator IS NULL + AND input_tokens_denominator IS NULL + AND output_credits_numerator IS NULL + AND output_tokens_denominator IS NULL + ) + OR + ( + kind = 'LLM' + AND asr_credits_numerator IS NULL + AND asr_millis_denominator IS NULL + AND input_credits_numerator > 0 + AND input_tokens_denominator > 0 + AND output_credits_numerator > 0 + AND output_tokens_denominator > 0 + ) + ) +) ENGINE = InnoDB; + +CREATE TABLE credit_reservations ( + id CHAR(36) NOT NULL, + user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + rate_version_id CHAR(36) NOT NULL, + provider VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + model VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + usage_kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + estimated_asr_millis BIGINT NULL, + estimated_input_tokens BIGINT NULL, + estimated_output_tokens BIGINT NULL, + actual_asr_millis BIGINT NULL, + actual_input_tokens BIGINT NULL, + actual_output_tokens BIGINT NULL, + reserved_credits BIGINT NOT NULL, + settled_credits BIGINT NULL, + status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + managed_call BOOLEAN NOT NULL, + reserve_idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + settle_idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL, + release_idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL, + refund_idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uk_credit_reservation_reserve (user_id, reserve_idempotency_key), + UNIQUE KEY uk_credit_reservation_settle (user_id, settle_idempotency_key), + UNIQUE KEY uk_credit_reservation_release (user_id, release_idempotency_key), + UNIQUE KEY uk_credit_reservation_refund (user_id, refund_idempotency_key), + INDEX idx_credit_reservations_user_status (user_id, status), + CONSTRAINT fk_credit_reservations_rate + FOREIGN KEY (rate_version_id) REFERENCES credit_rate_versions (id) ON DELETE RESTRICT, + CONSTRAINT chk_credit_reservations_positive CHECK (reserved_credits > 0), + CONSTRAINT chk_credit_reservations_estimated_usage CHECK ( + ( + usage_kind = 'ASR' + AND estimated_asr_millis >= 0 + AND estimated_input_tokens IS NULL + AND estimated_output_tokens IS NULL + ) + OR + ( + usage_kind = 'LLM' + AND estimated_asr_millis IS NULL + AND estimated_input_tokens >= 0 + AND estimated_output_tokens >= 0 + ) + ), + CONSTRAINT chk_credit_reservations_state CHECK ( + ( + status = 'RESERVED' + AND actual_asr_millis IS NULL + AND actual_input_tokens IS NULL + AND actual_output_tokens IS NULL + AND settled_credits IS NULL + AND settle_idempotency_key IS NULL + AND release_idempotency_key IS NULL + AND refund_idempotency_key IS NULL + ) + OR + ( + status = 'SETTLED' + AND settled_credits >= 0 + AND settle_idempotency_key IS NOT NULL + AND release_idempotency_key IS NULL + AND refund_idempotency_key IS NULL + AND ( + ( + usage_kind = 'ASR' + AND actual_asr_millis >= 0 + AND actual_input_tokens IS NULL + AND actual_output_tokens IS NULL + ) + OR + ( + usage_kind = 'LLM' + AND actual_asr_millis IS NULL + AND actual_input_tokens >= 0 + AND actual_output_tokens >= 0 + ) + ) + ) + OR + ( + status = 'RELEASED' + AND actual_asr_millis IS NULL + AND actual_input_tokens IS NULL + AND actual_output_tokens IS NULL + AND settled_credits IS NULL + AND settle_idempotency_key IS NULL + AND release_idempotency_key IS NOT NULL + AND refund_idempotency_key IS NULL + ) + OR + ( + status = 'REFUNDED' + AND settled_credits >= 0 + AND settle_idempotency_key IS NOT NULL + AND release_idempotency_key IS NULL + AND refund_idempotency_key IS NOT NULL + AND ( + ( + usage_kind = 'ASR' + AND actual_asr_millis >= 0 + AND actual_input_tokens IS NULL + AND actual_output_tokens IS NULL + ) + OR + ( + usage_kind = 'LLM' + AND actual_asr_millis IS NULL + AND actual_input_tokens >= 0 + AND actual_output_tokens >= 0 + ) + ) + ) + ) +) ENGINE = InnoDB; + +CREATE TABLE referral_campaigns ( + id CHAR(36) NOT NULL, + name VARCHAR(100) NOT NULL, + starts_at DATETIME(6) NOT NULL, + ends_at DATETIME(6) NULL, + binding_window_seconds BIGINT NOT NULL, + inviter_reward_credits BIGINT NOT NULL, + invitee_reward_credits BIGINT NOT NULL, + max_rewarded_bindings BIGINT NULL, + budget_credits BIGINT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + INDEX idx_referral_campaigns_active (enabled, starts_at, ends_at), + CONSTRAINT chk_referral_campaign_interval + CHECK (ends_at IS NULL OR ends_at > starts_at), + CONSTRAINT chk_referral_campaign_values CHECK ( + binding_window_seconds > 0 + AND inviter_reward_credits > 0 + AND invitee_reward_credits > 0 + AND inviter_reward_credits <= 9223372036854775807 - invitee_reward_credits + AND (max_rewarded_bindings IS NULL OR max_rewarded_bindings > 0) + AND ( + budget_credits IS NULL + OR ( + budget_credits >= inviter_reward_credits + AND budget_credits - inviter_reward_credits >= invitee_reward_credits + ) + ) + ) +) ENGINE = InnoDB; + +CREATE TABLE referral_campaign_budgets ( + campaign_id CHAR(36) NOT NULL, + rewarded_bindings BIGINT NOT NULL DEFAULT 0, + spent_credits BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (campaign_id), + CONSTRAINT fk_referral_campaign_budget_campaign + FOREIGN KEY (campaign_id) REFERENCES referral_campaigns (id) ON DELETE CASCADE, + CONSTRAINT chk_referral_campaign_budget_non_negative + CHECK (rewarded_bindings >= 0 AND spent_credits >= 0) +) ENGINE = InnoDB; + +INSERT INTO referral_campaigns ( + id, name, starts_at, binding_window_seconds, + inviter_reward_credits, invitee_reward_credits, enabled +) VALUES ( + '00000000-0000-0000-0000-000000000001', + 'Default referral campaign', + '1970-01-01 00:00:00.000000', + 604800, + 3000, + 3000, + TRUE +); + +INSERT INTO referral_campaign_budgets (campaign_id) +VALUES ('00000000-0000-0000-0000-000000000001'); + +CREATE TABLE referral_codes ( + id CHAR(36) NOT NULL, + owner_user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + campaign_id CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', + code VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uk_referral_codes_owner_campaign (owner_user_id, campaign_id), + UNIQUE KEY uk_referral_codes_code (code), + CONSTRAINT fk_referral_codes_campaign + FOREIGN KEY (campaign_id) REFERENCES referral_campaigns (id) ON DELETE RESTRICT +) ENGINE = InnoDB; + +CREATE TABLE referral_bindings ( + id CHAR(36) NOT NULL, + inviter_user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + invitee_user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + code_id CHAR(36) NOT NULL, + campaign_id CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', + bound_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + rewarded_at DATETIME(6) NULL, + reward_settlement_id CHAR(36) NULL, + reward_status VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin + NOT NULL DEFAULT 'PENDING', + PRIMARY KEY (id), + UNIQUE KEY uk_referral_bindings_invitee (invitee_user_id), + UNIQUE KEY uk_referral_bindings_settlement (reward_settlement_id), + INDEX idx_referral_bindings_inviter (inviter_user_id), + CONSTRAINT fk_referral_bindings_code + FOREIGN KEY (code_id) REFERENCES referral_codes (id) ON DELETE RESTRICT, + CONSTRAINT fk_referral_bindings_campaign + FOREIGN KEY (campaign_id) REFERENCES referral_campaigns (id) ON DELETE RESTRICT, + CONSTRAINT chk_referral_bindings_no_self CHECK (inviter_user_id <> invitee_user_id), + CONSTRAINT chk_referral_bindings_reward_pair CHECK ( + ( + reward_status IN ('PENDING', 'INELIGIBLE_BUDGET') + AND rewarded_at IS NULL + AND reward_settlement_id IS NULL + ) + OR + ( + reward_status = 'REWARDED' + AND rewarded_at IS NOT NULL + AND reward_settlement_id IS NOT NULL + ) + ) +) ENGINE = InnoDB; + +CREATE TABLE credit_usage_records ( + id CHAR(36) NOT NULL, + reservation_id CHAR(36) NOT NULL, + user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + rate_version_id CHAR(36) NOT NULL, + usage_kind VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + asr_millis BIGINT NULL, + input_tokens BIGINT NULL, + output_tokens BIGINT NULL, + charged_credits BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uk_credit_usage_reservation (reservation_id), + INDEX idx_credit_usage_user_created (user_id, created_at), + CONSTRAINT fk_credit_usage_reservation + FOREIGN KEY (reservation_id) REFERENCES credit_reservations (id) ON DELETE CASCADE, + CONSTRAINT fk_credit_usage_rate + FOREIGN KEY (rate_version_id) REFERENCES credit_rate_versions (id) ON DELETE RESTRICT, + CONSTRAINT fk_credit_usage_account + FOREIGN KEY (user_id) REFERENCES credit_accounts (user_id) ON DELETE CASCADE, + CONSTRAINT chk_credit_usage_credits CHECK (charged_credits >= 0), + CONSTRAINT chk_credit_usage_shape CHECK ( + ( + usage_kind = 'ASR' + AND asr_millis >= 0 + AND input_tokens IS NULL + AND output_tokens IS NULL + ) + OR + ( + usage_kind = 'LLM' + AND asr_millis IS NULL + AND input_tokens >= 0 + AND output_tokens >= 0 + ) + ) +) ENGINE = InnoDB; + +CREATE TABLE credit_ledger ( + id CHAR(36) NOT NULL, + user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + entry_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + amount_delta BIGINT NOT NULL, + balance_after BIGINT NOT NULL, + idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + reference_id CHAR(36) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uk_credit_ledger_idempotency (user_id, idempotency_key), + INDEX idx_credit_ledger_user_created (user_id, created_at, id), + INDEX idx_credit_ledger_reference (reference_id), + CONSTRAINT chk_credit_ledger_balance CHECK (balance_after >= 0), + CONSTRAINT chk_credit_ledger_key_length + CHECK (CHAR_LENGTH(idempotency_key) BETWEEN 8 AND 128), + CONSTRAINT chk_credit_ledger_type CHECK ( + entry_type IN ( + 'SIGNUP_TRIAL', + 'MANUAL_GRANT', + 'USAGE_RESERVE', + 'USAGE_SETTLE', + 'USAGE_RELEASE', + 'USAGE_REFUND', + 'REFERRAL_INVITER', + 'REFERRAL_INVITEE', + 'STOREKIT_PURCHASE', + 'SUBSCRIPTION_GRANT' + ) + ) +) ENGINE = InnoDB; + +-- The application repository exposes append-only operations for ledger, rate, +-- and usage tables. Production additionally grants the runtime user SELECT and +-- INSERT only on these tables. Avoiding stored triggers keeps migrations +-- compatible with binary-logged MySQL without granting global SUPER privilege. diff --git a/src/main/resources/db/migration/V3__gateway_grants_and_provider_requests.sql b/src/main/resources/db/migration/V3__gateway_grants_and_provider_requests.sql new file mode 100644 index 0000000..d33aa13 --- /dev/null +++ b/src/main/resources/db/migration/V3__gateway_grants_and_provider_requests.sql @@ -0,0 +1,76 @@ +CREATE TABLE provider_requests ( + request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + provider_id VARCHAR(64) NOT NULL, + capability VARCHAR(32) NOT NULL, + status VARCHAR(24) NOT NULL, + provider_request_id VARCHAR(128) NULL, + server_duration_millis BIGINT UNSIGNED NULL, + error_code VARCHAR(96) NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + completed_at TIMESTAMP(6) NULL, + PRIMARY KEY (request_id), + INDEX idx_provider_requests_account_created (account_id, created_at), + INDEX idx_provider_requests_provider_created (provider_id, created_at), + CONSTRAINT fk_provider_requests_account + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE usage_records ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + meter VARCHAR(32) NOT NULL, + units BIGINT UNSIGNED NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uq_usage_records_request_meter (request_id, meter), + CONSTRAINT fk_usage_records_provider_request + FOREIGN KEY (request_id) REFERENCES provider_requests (request_id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE gateway_grants ( + id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + idempotency_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + expires_at TIMESTAMP(6) NOT NULL, + revoked_at TIMESTAMP(6) NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_gateway_grants_account_idempotency (account_id, idempotency_key), + INDEX idx_gateway_grants_account_active (account_id, revoked_at, expires_at), + CONSTRAINT fk_gateway_grants_account + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE gateway_grant_scopes ( + grant_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + capability VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + PRIMARY KEY (grant_id, capability), + CONSTRAINT fk_gateway_grant_scopes_grant + FOREIGN KEY (grant_id) REFERENCES gateway_grants (id) ON DELETE CASCADE, + CONSTRAINT chk_gateway_grant_scope + CHECK (capability IN ('POLISH', 'AI', 'AGENT', 'ASR')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE gateway_refresh_tokens ( + id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + grant_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + family_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + replaced_by_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + rotation_idempotency_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL, + expires_at TIMESTAMP(6) NOT NULL, + revoked_at TIMESTAMP(6) NULL, + reuse_detected_at TIMESTAMP(6) NULL, + created_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_gateway_refresh_token_hash (token_hash), + INDEX idx_gateway_refresh_grant (grant_id), + INDEX idx_gateway_refresh_family (family_id), + CONSTRAINT fk_gateway_refresh_grant + FOREIGN KEY (grant_id) REFERENCES gateway_grants (id) ON DELETE CASCADE, + CONSTRAINT fk_gateway_refresh_replacement + FOREIGN KEY (replaced_by_id) REFERENCES gateway_refresh_tokens (id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V4__integrity.sql b/src/main/resources/db/migration/V4__integrity.sql new file mode 100644 index 0000000..953f4f2 --- /dev/null +++ b/src/main/resources/db/migration/V4__integrity.sql @@ -0,0 +1,51 @@ +CREATE TABLE devicecheck_trial_claims ( + device_token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (device_token_hash), + KEY ix_devicecheck_trial_account (account_id), + CONSTRAINT fk_devicecheck_trial_account + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE, + CONSTRAINT chk_devicecheck_trial_status + CHECK (status IN ('RESERVED', 'APPLE_MARKED', 'COMPLETED', 'REJECTED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE app_attest_challenges ( + id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + key_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + purpose VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + challenge_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + expires_at DATETIME(6) NOT NULL, + consumed_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + KEY ix_app_attest_challenge_expiry (expires_at), + KEY ix_app_attest_challenge_account (account_id), + CONSTRAINT fk_app_attest_challenge_account + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE, + CONSTRAINT chk_app_attest_challenge_purpose + CHECK (purpose IN ('ATTESTATION', 'ASSERTION')), + CONSTRAINT chk_app_attest_challenge_status + CHECK (status IN ('ISSUED', 'CONSUMED', 'EXPIRED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE app_attest_keys ( + key_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + public_key_base64 VARCHAR(512) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + receipt_base64 MEDIUMTEXT CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + sign_counter BIGINT NOT NULL DEFAULT 0, + account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (key_id), + KEY ix_app_attest_keys_account (account_id), + CONSTRAINT fk_app_attest_keys_account + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE SET NULL, + CONSTRAINT chk_app_attest_counter_non_negative CHECK (sign_counter >= 0), + CONSTRAINT chk_app_attest_key_status CHECK (status IN ('ACTIVE', 'REVOKED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/src/main/resources/db/migration/V5__account_security_hardening.sql b/src/main/resources/db/migration/V5__account_security_hardening.sql new file mode 100644 index 0000000..4d7d6be --- /dev/null +++ b/src/main/resources/db/migration/V5__account_security_hardening.sql @@ -0,0 +1,113 @@ +ALTER TABLE accounts + ADD COLUMN identity_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, + ADD COLUMN anti_abuse_restricted BOOLEAN NOT NULL DEFAULT FALSE, + ADD UNIQUE KEY uq_accounts_identity_fingerprint (identity_fingerprint); + +CREATE TABLE account_identity_tombstones ( + identity_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + deleted_at DATETIME(6) NOT NULL, + expires_at DATETIME(6) NOT NULL, + PRIMARY KEY (identity_fingerprint), + INDEX ix_account_tombstones_expiry (expires_at), + CONSTRAINT chk_account_tombstone_interval CHECK (expires_at > deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE apple_revocation_outbox ( + id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + encrypted_refresh_token MEDIUMTEXT NULL, + created_at DATETIME(6) NOT NULL, + next_attempt_at DATETIME(6) NOT NULL, + attempt_count INT NOT NULL DEFAULT 0, + completed_at DATETIME(6) NULL, + PRIMARY KEY (id), + INDEX ix_apple_revocation_pending (completed_at, next_attempt_at), + CONSTRAINT chk_apple_revocation_attempts CHECK (attempt_count >= 0), + CONSTRAINT chk_apple_revocation_token_lifecycle CHECK ( + (completed_at IS NULL AND encrypted_refresh_token IS NOT NULL) + OR + (completed_at IS NOT NULL AND encrypted_refresh_token IS NULL) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- Existing V2/V3 tables predated account foreign keys. Remove only already-orphaned +-- mutable state before enforcing referential integrity. The immutable credit ledger +-- intentionally remains pseudonymized by its now-unmapped random account UUID. +DELETE ur +FROM usage_records ur +LEFT JOIN provider_requests pr ON pr.request_id = ur.request_id +WHERE pr.request_id IS NULL; + +DELETE rb +FROM referral_bindings rb +LEFT JOIN accounts inviter ON inviter.id = rb.inviter_user_id +LEFT JOIN accounts invitee ON invitee.id = rb.invitee_user_id +LEFT JOIN referral_codes rc ON rc.id = rb.code_id +LEFT JOIN accounts code_owner ON code_owner.id = rc.owner_user_id +WHERE inviter.id IS NULL + OR invitee.id IS NULL + OR rc.id IS NULL + OR code_owner.id IS NULL + OR rc.owner_user_id <> rb.inviter_user_id; + +DELETE rc +FROM referral_codes rc +LEFT JOIN accounts a ON a.id = rc.owner_user_id +WHERE a.id IS NULL; + +DELETE cr +FROM credit_reservations cr +LEFT JOIN accounts a ON a.id = cr.user_id +WHERE a.id IS NULL; + +DELETE ca +FROM credit_accounts ca +LEFT JOIN accounts a ON a.id = ca.user_id +WHERE a.id IS NULL; + +DELETE ur +FROM usage_records ur +JOIN provider_requests pr ON pr.request_id = ur.request_id +LEFT JOIN accounts a ON a.id = pr.account_id +WHERE a.id IS NULL; + +DELETE pr +FROM provider_requests pr +LEFT JOIN accounts a ON a.id = pr.account_id +WHERE a.id IS NULL; + +DELETE gg +FROM gateway_grants gg +LEFT JOIN accounts a ON a.id = gg.account_id +WHERE a.id IS NULL; + +ALTER TABLE credit_accounts + ADD CONSTRAINT fk_credit_accounts_user + FOREIGN KEY (user_id) REFERENCES accounts (id) ON DELETE CASCADE; + +ALTER TABLE credit_reservations + ADD CONSTRAINT fk_credit_reservations_user + FOREIGN KEY (user_id) REFERENCES accounts (id) ON DELETE CASCADE; + +ALTER TABLE referral_bindings + DROP FOREIGN KEY fk_referral_bindings_code; + +ALTER TABLE referral_bindings + ADD CONSTRAINT fk_referral_bindings_code + FOREIGN KEY (code_id) REFERENCES referral_codes (id) ON DELETE CASCADE, + ADD CONSTRAINT fk_referral_bindings_inviter + FOREIGN KEY (inviter_user_id) REFERENCES accounts (id) ON DELETE CASCADE, + ADD CONSTRAINT fk_referral_bindings_invitee + FOREIGN KEY (invitee_user_id) REFERENCES accounts (id) ON DELETE CASCADE; + +ALTER TABLE referral_codes + ADD COLUMN owner_identity_fingerprint CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, + ADD CONSTRAINT fk_referral_codes_owner + FOREIGN KEY (owner_user_id) REFERENCES accounts (id) ON DELETE CASCADE; + +ALTER TABLE usage_records + DROP FOREIGN KEY fk_usage_records_provider_request; + +ALTER TABLE usage_records + ADD CONSTRAINT fk_usage_records_provider_request + FOREIGN KEY (request_id) REFERENCES provider_requests (request_id) ON DELETE CASCADE; + diff --git a/src/main/resources/db/migration/V6__gateway_execution_state.sql b/src/main/resources/db/migration/V6__gateway_execution_state.sql new file mode 100644 index 0000000..c09c8c8 --- /dev/null +++ b/src/main/resources/db/migration/V6__gateway_execution_state.sql @@ -0,0 +1,65 @@ +-- Make client request IDs idempotent within an account, not globally. +ALTER TABLE usage_records + DROP FOREIGN KEY fk_usage_records_provider_request, + DROP INDEX uq_usage_records_request_meter, + ADD COLUMN account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER id; + +UPDATE usage_records ur +JOIN provider_requests pr ON pr.request_id = ur.request_id +SET ur.account_id = pr.account_id; + +ALTER TABLE usage_records + MODIFY account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL; + +-- Normalize every legacy value before installing the binary status column and +-- stricter CHECK. Unknown historical states are held for manual review rather +-- than making a non-transactional MySQL migration fail halfway through. +UPDATE provider_requests +SET status = CASE UPPER(status) + WHEN 'SUCCEEDED' THEN 'SETTLED' + WHEN 'FAILED' THEN 'MANUAL_REVIEW' + WHEN 'CLAIMED' THEN 'CLAIMED' + WHEN 'STARTED' THEN 'STARTED' + WHEN 'SETTLEMENT_PENDING' THEN 'SETTLEMENT_PENDING' + WHEN 'SETTLED' THEN 'SETTLED' + WHEN 'RELEASED' THEN 'RELEASED' + WHEN 'MANUAL_REVIEW' THEN 'MANUAL_REVIEW' + ELSE 'MANUAL_REVIEW' +END; + +ALTER TABLE provider_requests + ADD COLUMN reservation_id CHAR(36) NULL AFTER account_id, + ADD COLUMN usage_meter VARCHAR(32) NULL AFTER provider_request_id, + ADD COLUMN usage_units BIGINT UNSIGNED NULL AFTER usage_meter, + ADD COLUMN usage_input_units BIGINT UNSIGNED NULL AFTER usage_units, + ADD COLUMN usage_output_units BIGINT UNSIGNED NULL AFTER usage_input_units, + MODIFY COLUMN status VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + DROP PRIMARY KEY, + ADD PRIMARY KEY (account_id, request_id), + ADD UNIQUE KEY uq_provider_requests_reservation (reservation_id), + ADD INDEX idx_provider_requests_status_created (status, created_at), + ADD CONSTRAINT chk_provider_request_status CHECK ( + status IN ( + 'CLAIMED', + 'STARTED', + 'SETTLEMENT_PENDING', + 'SETTLED', + 'RELEASED', + 'MANUAL_REVIEW' + ) + ); + +ALTER TABLE usage_records + ADD UNIQUE KEY uq_usage_records_account_request_meter (account_id, request_id, meter), + ADD CONSTRAINT fk_usage_records_provider_request + FOREIGN KEY (account_id, request_id) + REFERENCES provider_requests (account_id, request_id) + ON DELETE CASCADE; + +-- A rotated refresh row points to its replacement. RESTRICT can block the +-- account -> grant -> refresh cascade when an account is deleted. +ALTER TABLE gateway_refresh_tokens + DROP FOREIGN KEY fk_gateway_refresh_replacement, + ADD CONSTRAINT fk_gateway_refresh_replacement + FOREIGN KEY (replaced_by_id) REFERENCES gateway_refresh_tokens (id) + ON DELETE SET NULL; diff --git a/src/main/resources/db/migration/V7__initial_managed_rates.sql b/src/main/resources/db/migration/V7__initial_managed_rates.sql new file mode 100644 index 0000000..f41754e --- /dev/null +++ b/src/main/resources/db/migration/V7__initial_managed_rates.sql @@ -0,0 +1,49 @@ +-- Initial integer-credit rate cards. Future price changes must insert a new +-- immutable version and close the previous effective interval. +INSERT INTO credit_rate_versions ( + id, + kind, + provider, + model, + effective_from, + effective_until, + asr_credits_numerator, + asr_millis_denominator, + created_at +) VALUES ( + '10000000-0000-0000-0000-000000000001', + 'ASR', + 'volcengine-sauc-v3', + 'volc.seedasr.sauc.duration', + '1970-01-01 00:00:00.000000', + NULL, + 1, + 1000, + UTC_TIMESTAMP(6) +); + +INSERT INTO credit_rate_versions ( + id, + kind, + provider, + model, + effective_from, + effective_until, + input_credits_numerator, + input_tokens_denominator, + output_credits_numerator, + output_tokens_denominator, + created_at +) VALUES ( + '10000000-0000-0000-0000-000000000002', + 'LLM', + 'deepseek', + 'deepseek-v4-flash', + '1970-01-01 00:00:00.000000', + NULL, + 1, + 100, + 2, + 100, + UTC_TIMESTAMP(6) +); diff --git a/src/main/resources/invite/apple-app-site-association.json b/src/main/resources/invite/apple-app-site-association.json new file mode 100644 index 0000000..d34e48e --- /dev/null +++ b/src/main/resources/invite/apple-app-site-association.json @@ -0,0 +1,17 @@ +{ + "applinks": { + "details": [ + { + "appIDs": [ + "{{APPLE_APP_ID}}" + ], + "components": [ + { + "/": "/i/*", + "comment": "Open first-party OSG invitation links in the app" + } + ] + } + ] + } +} diff --git a/src/main/resources/invite/index.html b/src/main/resources/invite/index.html new file mode 100644 index 0000000..7f28f34 --- /dev/null +++ b/src/main/resources/invite/index.html @@ -0,0 +1,184 @@ + + + + + + + + OSG 邀请 / Invitation + + + +
+ +

+ 加入 OSG + Join OSG +

+

+ 使用此邀请码开始体验。 + Use this invitation code to get started. +

+ {{INVITE_CODE}} + +

+

+ 若“打开 App”仍停留在浏览器,请从信息或邮件中再次轻点原邀请链接。 + If “Open App” stays in the browser, tap the original invitation link again from Messages or Mail. +

+
+ + + diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..ef17de9 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,45 @@ + + + + {"time":"%date{ISO8601}","level":"%level","logger":"%logger{36}","message":"%replace(%msg){'[\r\n]+',' '}"}%n + + + + + + + + + + + + + + + %date{ISO8601} %-5level [%thread] %logger{24} - %msg%n + + + + + + + + + + + + + + + %d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} %-5level [%thread] %logger{36} requestId=%X{requestId:-} - %msg%n + + + + + + + + + + + diff --git a/src/test/kotlin/com/osglab/account/ApplicationTest.kt b/src/test/kotlin/com/osglab/account/ApplicationTest.kt new file mode 100644 index 0000000..c7fc12d --- /dev/null +++ b/src/test/kotlin/com/osglab/account/ApplicationTest.kt @@ -0,0 +1,19 @@ +package com.osglab.account + +import io.kotest.matchers.shouldBe +import io.ktor.client.request.get +import io.ktor.http.HttpStatusCode +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import kotlin.test.Test + +class ApplicationTest { + @Test + fun `liveness endpoint remains independent of external services`() = testApplication { + application { + routing { healthRoutes() } + } + + client.get("/health/live").status shouldBe HttpStatusCode.OK + } +} diff --git a/src/test/kotlin/com/osglab/account/common/api/ApiContractTest.kt b/src/test/kotlin/com/osglab/account/common/api/ApiContractTest.kt new file mode 100644 index 0000000..4cedc71 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/common/api/ApiContractTest.kt @@ -0,0 +1,84 @@ +package com.osglab.account.common.api + +import com.osglab.account.common.errors.InvalidRequestException +import com.osglab.account.common.security.SESSION_AUTH_NAME +import com.osglab.account.common.security.installSessionAuthentication +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.auth.authenticate +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import kotlinx.serialization.json.Json + +class ApiContractTest : FunSpec({ + test("known API failures use the stable error envelope") { + testApplication { + application { + install(ContentNegotiation) { json(Json) } + installApiStatusPages() + routing { + get("/invalid") { + throw InvalidRequestException("Invalid input") + } + } + } + + val response = client.get("/invalid") + + response.status shouldBe HttpStatusCode.BadRequest + response.bodyAsText() shouldBe + """{"error":{"code":"invalid_request","message":"Invalid input"}}""" + } + } + + test("unexpected failures do not disclose exception details") { + testApplication { + application { + install(ContentNegotiation) { json(Json) } + installApiStatusPages() + routing { + get("/failure") { + error("database-password") + } + } + } + + val response = client.get("/failure") + + response.status shouldBe HttpStatusCode.InternalServerError + response.bodyAsText() shouldBe + """{"error":{"code":"internal_error","message":"An internal error occurred"}}""" + } + } + + test("authentication challenges use the same error envelope") { + testApplication { + application { + install(ContentNegotiation) { json(Json) } + installApiStatusPages() + installSessionAuthentication { null } + routing { + authenticate(SESSION_AUTH_NAME) { + get("/protected") { + call.respondText("unreachable") + } + } + } + } + + val response = client.get("/protected") + + response.status shouldBe HttpStatusCode.Unauthorized + response.bodyAsText() shouldBe + """{"error":{"code":"unauthorized","message":"Authentication required"}}""" + } + } +}) diff --git a/src/test/kotlin/com/osglab/account/common/security/SecurityPrimitivesTest.kt b/src/test/kotlin/com/osglab/account/common/security/SecurityPrimitivesTest.kt new file mode 100644 index 0000000..2561641 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/common/security/SecurityPrimitivesTest.kt @@ -0,0 +1,53 @@ +package com.osglab.account.common.security + +import com.osglab.account.config.SessionConfig +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID + +class SecurityPrimitivesTest : FunSpec({ + test("refresh token hashes are deterministic without storing the token") { + TokenHash.sha256("secret") shouldBe + "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b" + TokenHash.matches("secret", TokenHash.sha256("secret")) shouldBe true + TokenHash.matches("different", TokenHash.sha256("secret")) shouldBe false + } + + test("AES-GCM uses unique nonces and authenticates context") { + val encryptor = FieldEncryptor(ByteArray(32) { 3 }) + val first = encryptor.encrypt("apple-refresh-token", "account:1") + val second = encryptor.encrypt("apple-refresh-token", "account:1") + + first shouldNotBe second + encryptor.decrypt(first, "account:1") shouldBe "apple-refresh-token" + shouldThrow { + encryptor.decrypt(first, "account:2") + } + } + + test("session JWT validates issuer audience signature and claims") { + val clock = Clock.fixed(Instant.parse("2026-08-15T12:00:00Z"), ZoneOffset.UTC) + val jwt = SessionJwt( + SessionConfig( + issuer = "https://issuer.example", + audience = "ios", + hmacSecret = ByteArray(32) { 9 }, + accessMinutes = 15, + refreshDays = 30, + ), + clock, + ) + val accountId = UUID.randomUUID() + val sessionId = UUID.randomUUID() + val issued = jwt.issue(accountId, sessionId) + + jwt.verify(issued.value)?.userId shouldBe accountId + jwt.verify(issued.value)?.sessionId shouldBe sessionId + jwt.verify(issued.value + "tampered") shouldBe null + } +}) diff --git a/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt b/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt new file mode 100644 index 0000000..198c507 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt @@ -0,0 +1,153 @@ +package com.osglab.account.config + +import io.ktor.server.config.MapApplicationConfig +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import java.util.Base64 + +class AppConfigTest : FunSpec({ + test("test configuration can be injected without Apple client credentials") { + val config = AppConfig.from(validConfig("test")) + + config.environment shouldBe Environment.TEST + config.apple.clientCredentialsAvailable shouldBe false + config.encryption.key.size shouldBe 32 + } + + test("production rejects placeholder secrets") { + val config = validProductionConfig().apply { + put("app.session.secret", "replace-with-secret") + } + + shouldThrow { + AppConfig.from(config) + } + } + + test("production accepts complete independent configuration") { + val config = AppConfig.from(validProductionConfig()) + + config.environment shouldBe Environment.PRODUCTION + config.database.username shouldBe "test" + config.database.migrationUsername shouldBe "test_migrator" + } + + test("production fails fast when Apple signing credentials are missing") { + val config = validProductionConfig().apply { + put("app.apple.keyId", "") + } + + shouldThrow { + AppConfig.from(config) + }.message.orEmpty() shouldContain "app.apple.keyId" + } + + test("production rejects monitor-only integrity configuration") { + val config = validProductionConfig().apply { + put("app.providers.volcengine.apiKey", "volcengine-key") + put("app.providers.deepseek.apiKey", "deepseek-key") + put("app.integrity.enforceDeviceCheck", "false") + put("app.integrity.enforceAppAttest", "false") + } + + shouldThrow { + AppConfig.from(config) + }.message.orEmpty() shouldContain "must enforce both DeviceCheck and App Attest" + } + + test("production rejects provider endpoints outside the exact host allowlist") { + val config = validProductionConfig().apply { + put("app.providers.deepseek.endpoint", "https://127.0.0.1/v1") + } + + shouldThrow { + AppConfig.from(config) + }.message.orEmpty() shouldContain "DeepSeek endpoint" + } + + test("production requires separate migration credentials") { + val missingMigrator = validProductionConfig().apply { + put("app.database.migrationUsername", "") + } + shouldThrow { + AppConfig.from(missingMigrator) + }.message.orEmpty() shouldContain "app.database.migrationUsername" + + val reusedPassword = validProductionConfig().apply { + put("app.database.migrationPassword", "database-password") + } + shouldThrow { + AppConfig.from(reusedPassword) + }.message.orEmpty() shouldContain "passwords must be distinct" + } + + test("production requires independent cryptographic secrets") { + val config = validProductionConfig().apply { + put( + "app.antiAbuse.identityHmacKeyBase64", + Base64.getEncoder().encodeToString(ByteArray(32) { 7 }), + ) + } + + shouldThrow { + AppConfig.from(config) + }.message.orEmpty() shouldContain "must be distinct" + } + + test("production requires exact public and App Store URLs") { + val publicUrl = validProductionConfig().apply { + put("app.publicBaseUrl", "https://account.osglab.com.evil.example") + } + shouldThrow { + AppConfig.from(publicUrl) + }.message.orEmpty() shouldContain "PUBLIC_BASE_URL" + + val appStoreUrl = validProductionConfig().apply { + put("app.appStoreUrl", "https://apps.apple.com/app/id0000000000") + } + shouldThrow { + AppConfig.from(appStoreUrl) + }.message.orEmpty() shouldContain "APP_STORE_URL" + } +}) + +private fun validConfig(environment: String) = MapApplicationConfig( + "app.environment" to environment, + "app.database.jdbcUrl" to "jdbc:mysql://localhost:3306/test", + "app.database.username" to "test", + "app.database.password" to "database-password", + "app.database.migrationUsername" to "test_migrator", + "app.database.migrationPassword" to "migration-password", + "app.database.maximumPoolSize" to "4", + "app.session.issuer" to "https://issuer.example", + "app.session.audience" to "ios-app", + "app.session.secret" to "01234567890123456789012345678901", + "app.session.accessMinutes" to "15", + "app.session.refreshDays" to "30", + "app.encryption.keyBase64" to Base64.getEncoder().encodeToString(ByteArray(32) { 7 }), + "app.antiAbuse.identityHmacKeyBase64" to + Base64.getEncoder().encodeToString(ByteArray(32) { 8 }), + "app.apple.clientId" to "com.example.app", + "app.apple.jwksUrl" to "https://appleid.apple.com/auth/keys", + "app.apple.tokenUrl" to "https://appleid.apple.com/auth/token", + "app.apple.revokeUrl" to "https://appleid.apple.com/auth/revoke", + "app.integrity.enforceDeviceCheck" to "false", + "app.integrity.enforceAppAttest" to "false", +) + +private fun validProductionConfig() = validConfig("production").apply { + put("app.publicBaseUrl", "https://account.osglab.com") + put("app.inviteBaseUrl", "https://osglab.com/i") + put("app.appStoreUrl", "https://apps.apple.com/app/id1234567890") + put("app.apple.teamId", APP_ATTEST_TEAM_ID) + put("app.apple.keyId", "APPLE_KEY") + put("app.apple.clientId", APP_ATTEST_BUNDLE_ID) + put("app.apple.privateKeyPem", "private-key-material") + put("app.integrity.appleEnvironment", "production") + put("app.integrity.enforceDeviceCheck", "true") + put("app.integrity.enforceAppAttest", "true") + put("app.providers.volcengine.apiKey", "volcengine-key") + put("app.providers.deepseek.apiKey", "deepseek-key") +} diff --git a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt new file mode 100644 index 0000000..42818e4 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt @@ -0,0 +1,119 @@ +package com.osglab.account.config + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import java.nio.file.Files +import java.nio.file.Path + +class DeploymentConsistencyTest : FunSpec({ + val root = Path.of(System.getProperty("user.dir")) + + test("OpenAPI documents every mounted public route") { + val openApi = root.read("docs/openapi.yaml") + val documentedPaths = Regex("""(?m)^ (/[^:]+):\s*$""") + .findAll(openApi) + .map { it.groupValues[1] } + .toSet() + + documentedPaths shouldBe EXPECTED_PUBLIC_PATHS + } + + test("production Compose reuses private MySQL and hardens the application container") { + val compose = root.read("compose.yaml") + + compose shouldContain "127.0.0.1:\${ACCOUNT_BIND_PORT:-18080}:8080" + compose shouldContain "external: true" + compose shouldContain "account-egress:" + compose shouldContain "user: \"10001:10001\"" + compose shouldContain "read_only: true" + compose shouldContain "cap_drop:" + compose shouldContain "no-new-privileges:true" + compose shouldNotContain "image: mysql" + compose shouldNotContain "3306:3306" + compose shouldNotContain "0.0.0.0:" + } + + test("container image remains non-root and read-only compatible") { + val dockerfile = root.read("Dockerfile") + + dockerfile shouldContain "USER 10001:10001" + dockerfile shouldContain "ENV HOME=/tmp" + dockerfile shouldNotContain "ENTRYPOINT [\"sh\"" + } + + test("OpenResty proxies HTTP WebSocket invitations and both AASA paths safely") { + val openResty = root.read("deploy/openresty-account.conf") + + openResty shouldContain "proxy_set_header Upgrade \$http_upgrade;" + openResty shouldContain "proxy_set_header Connection \$connection_upgrade;" + openResty shouldContain "location = /.well-known/apple-app-site-association" + openResty shouldContain "location = /apple-app-site-association" + openResty shouldContain "location ^~ /i/" + Regex("""location \^~ /i/ \{\s+access_log off;""").containsMatchIn(openResty) shouldBe true + openResty shouldNotContain "alias /www/wwwroot/osglab.com/apple-app-site-association" + } + + test("CI definition is singular and leaves MySQL lifecycle to Testcontainers") { + val ci = root.read(".github/workflows/ci.yml") + + Regex("""(?m)^name: CI$""").findAll(ci).count() shouldBe 1 + Regex("""(?m)^jobs:$""").findAll(ci).count() shouldBe 1 + ci shouldContain "docker compose -f compose.yaml config --quiet" + ci shouldContain "./gradlew --no-daemon clean test" + ci shouldContain "./gradlew --no-daemon buildFatJar" + ci shouldNotContain "3306:3306" + ci shouldNotContain "TEST_DB_" + } + + test("AASA has one runtime template and no deploy-time identifier placeholder") { + val aasa = root.read("src/main/resources/invite/apple-app-site-association.json") + + aasa shouldContain "\"{{APPLE_APP_ID}}\"" + aasa shouldContain "\"/i/*\"" + Files.exists(root.resolve("deploy/apple-app-site-association")) shouldBe false + } +}) + +private fun Path.read(relativePath: String): String = + Files.readString(resolve(relativePath)) + +private val EXPECTED_PUBLIC_PATHS = setOf( + "/health", + "/health/live", + "/health/ready", + "/v1/auth/apple", + "/v1/auth/refresh", + "/v1/auth/logout", + "/v1/account", + "/v1/apple/events", + "/v1/credits/balance", + "/v1/credits/ledger", + "/v1/credits/rates", + "/v1/credits/reservations", + "/v1/credits/reservations/{reservationId}", + "/v1/credits/reservations/{reservationId}/settle", + "/v1/credits/reservations/{reservationId}/release", + "/v1/credits/reservations/{reservationId}/refund", + "/v1/referrals", + "/v1/referrals/me", + "/v1/referrals/code", + "/v1/referrals/redeem", + "/v1/referrals/bind", + "/v1/referrals/campaigns", + "/v1/integrity/challenges", + "/v1/integrity/attest", + "/v1/integrity/assert", + "/v1/gateway/catalog", + "/v1/gateway/grants", + "/v1/gateway/grants/refresh", + "/v1/gateway/grants/{grantId}", + "/v1/gateway/llm/{capability}", + "/v1/gateway/asr", + "/v1/gateway/asr/sessions", + "/v1/gateway/asr/sessions/{sessionId}/stream", + "/.well-known/apple-app-site-association", + "/apple-app-site-association", + "/i/{code}", +) diff --git a/src/test/kotlin/com/osglab/account/features/account/AccountServiceTest.kt b/src/test/kotlin/com/osglab/account/features/account/AccountServiceTest.kt new file mode 100644 index 0000000..893b9bb --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/account/AccountServiceTest.kt @@ -0,0 +1,171 @@ +package com.osglab.account.features.account + +import com.osglab.account.common.errors.UnauthorizedException +import com.osglab.account.common.security.FieldEncryptor +import com.osglab.account.config.AntiAbuseConfig +import com.osglab.account.features.auth.AppleTokenClient +import com.osglab.account.features.auth.AppleTokenExchange +import com.osglab.account.features.auth.AppleClientUnavailableException +import com.osglab.account.features.auth.appleRefreshContext +import io.kotest.core.spec.style.FunSpec +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import java.time.Instant +import java.util.UUID + +class AccountServiceTest : FunSpec({ + test("account deletion commits locally before reliably revoking the Apple token") { + val accountId = UUID.randomUUID() + val encryptor = FieldEncryptor(ByteArray(32) { 5 }) + val events = mutableListOf() + val repository = RecordingAccountRepository( + AccountRecord( + id = accountId, + identityFingerprint = "a".repeat(64), + antiAbuseRestricted = false, + encryptedAppleRefreshToken = encryptor.encrypt( + "apple-refresh", + appleRefreshContext(accountId), + ), + createdAt = Instant.EPOCH, + ), + events, + ) + val appleClient = RecordingAppleTokenClient(events) + val processor = AppleRevocationOutboxProcessor(repository, appleClient, encryptor) + val service = AccountService( + repository, + encryptor, + AntiAbuseConfig(ByteArray(32) { 9 }, 365), + processor, + AccountReauthenticator { _, _ -> "apple-refresh" }, + ) + + service.delete(accountId, REAUTH_PROOF) + + appleClient.revokedToken shouldBe "apple-refresh" + repository.deleted shouldBe true + events shouldBe listOf("local-delete", "apple-revoke", "outbox-complete") + } + + test("Apple outage never rolls back local deletion and leaves durable outbox work") { + val accountId = UUID.randomUUID() + val encryptor = FieldEncryptor(ByteArray(32) { 5 }) + val events = mutableListOf() + val repository = RecordingAccountRepository( + AccountRecord( + id = accountId, + identityFingerprint = "b".repeat(64), + antiAbuseRestricted = false, + encryptedAppleRefreshToken = encryptor.encrypt( + "apple-refresh", + appleRefreshContext(accountId), + ), + createdAt = Instant.EPOCH, + ), + events, + ) + val appleClient = RecordingAppleTokenClient(events, unavailable = true) + val service = AccountService( + repository, + encryptor, + AntiAbuseConfig(ByteArray(32) { 9 }, 365), + AppleRevocationOutboxProcessor(repository, appleClient, encryptor), + AccountReauthenticator { _, _ -> "apple-refresh" }, + ) + + service.delete(accountId, REAUTH_PROOF) + + repository.deleted shouldBe true + repository.pendingCount shouldBe 1 + } + + test("account deletion requires recent matching Apple credentials") { + val accountId = UUID.randomUUID() + val encryptor = FieldEncryptor(ByteArray(32) { 5 }) + val repository = RecordingAccountRepository( + AccountRecord( + id = accountId, + identityFingerprint = "c".repeat(64), + antiAbuseRestricted = false, + encryptedAppleRefreshToken = null, + createdAt = Instant.EPOCH, + ), + mutableListOf(), + ) + val appleClient = RecordingAppleTokenClient(mutableListOf()) + val service = AccountService( + repository, + encryptor, + AntiAbuseConfig(ByteArray(32) { 9 }, 365), + AppleRevocationOutboxProcessor(repository, appleClient, encryptor), + AccountReauthenticator { _, _ -> throw UnauthorizedException() }, + ) + + shouldThrow { + service.delete(accountId, REAUTH_PROOF) + } + + repository.deleted shouldBe false + } +}) + +private val REAUTH_PROOF = AppleReauthenticationProof( + identityToken = "identity-token", + authorizationCode = "authorization-code", + nonce = "nonce", +) + +private class RecordingAccountRepository( + private val account: AccountRecord, + private val events: MutableList, +) : AccountRepository { + var deleted = false + private var pending: AppleRevocationOutboxRecord? = null + val pendingCount: Int get() = if (pending == null) 0 else 1 + + override suspend fun findById(accountId: UUID): AccountRecord? = account + + override suspend fun deleteById( + accountId: UUID, + deletedAt: Instant, + tombstoneExpiresAt: Instant, + createRevocation: (String?) -> NewAppleRevocation?, + ): Boolean { + deleted = true + events += "local-delete" + val revocation = createRevocation(account.encryptedAppleRefreshToken) + pending = revocation?.let { + AppleRevocationOutboxRecord(it.id, it.encryptedRefreshToken, 0) + } + return true + } + + override suspend fun pendingAppleRevocations( + now: Instant, + limit: Int, + ): List = listOfNotNull(pending) + + override suspend fun rescheduleAppleRevocation(id: UUID, nextAttemptAt: Instant) = Unit + + override suspend fun completeAppleRevocation(id: UUID, completedAt: Instant) { + pending = null + events += "outbox-complete" + } +} + +private class RecordingAppleTokenClient( + private val events: MutableList, + private val unavailable: Boolean = false, +) : AppleTokenClient { + var revokedToken: String? = null + + override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange = + error("Not used by account deletion") + + override suspend fun revokeRefreshToken(refreshToken: String) { + if (unavailable) throw AppleClientUnavailableException() + revokedToken = refreshToken + events += "apple-revoke" + } +} diff --git a/src/test/kotlin/com/osglab/account/features/appleevents/AppleEventRepositoryTest.kt b/src/test/kotlin/com/osglab/account/features/appleevents/AppleEventRepositoryTest.kt new file mode 100644 index 0000000..335eda0 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/appleevents/AppleEventRepositoryTest.kt @@ -0,0 +1,14 @@ +package com.osglab.account.features.appleevents + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue + +class AppleEventRepositoryTest : FunSpec({ + test("both official and observed Apple account deletion event names terminate accounts") { + isAccountTerminatingAppleEvent("account-delete").shouldBeTrue() + isAccountTerminatingAppleEvent("account-deleted").shouldBeTrue() + isAccountTerminatingAppleEvent("consent-revoked").shouldBeTrue() + isAccountTerminatingAppleEvent("email-enabled").shouldBeFalse() + } +}) diff --git a/src/test/kotlin/com/osglab/account/features/appleevents/AppleEventVerifierTest.kt b/src/test/kotlin/com/osglab/account/features/appleevents/AppleEventVerifierTest.kt new file mode 100644 index 0000000..e10a8df --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/appleevents/AppleEventVerifierTest.kt @@ -0,0 +1,114 @@ +package com.osglab.account.features.appleevents + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import com.osglab.account.config.AppleConfig +import com.osglab.account.features.auth.AppleJwksProvider +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.security.KeyPairGenerator +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.Date + +class AppleEventVerifierTest : FunSpec({ + val now = Instant.parse("2026-08-16T00:00:00Z") + val trustedKey = newRsaKey("apple-events") + val verifier = AppleEventVerifier( + config = AppleConfig( + teamId = null, + keyId = null, + clientId = "com.example.ios", + privateKeyPem = null, + jwksUrl = "https://appleid.apple.com/auth/keys", + tokenUrl = "https://appleid.apple.com/auth/token", + revokeUrl = "https://appleid.apple.com/auth/revoke", + ), + jwksProvider = object : AppleJwksProvider { + override suspend fun rsaKey(keyId: String): RSAKey? = + trustedKey.toPublicJWK().takeIf { keyId == trustedKey.keyID } + }, + clock = Clock.fixed(now, ZoneOffset.UTC), + ) + + test("accepts event fields only after the JWS signature is verified") { + val event = verifier.verify(signedEvent(trustedKey, now)) + + event shouldBe VerifiedAppleEvent( + eventId = "event-1", + type = "consent-revoked", + appleSubject = "apple-subject", + ) + } + + test("rejects an attacker-signed payload even when its claims look valid") { + val attackerKey = newRsaKey("apple-events") + + shouldThrow { + verifier.verify(signedEvent(attackerKey, now)) + } + } + + test("rejects an unsigned JSON payload") { + shouldThrow { + verifier.verify("""{"events":{"type":"account-delete","sub":"apple-subject"}}""") + } + } + + test("rejects an event without expiration or with ambiguous audiences") { + shouldThrow { + verifier.verify(signedEvent(trustedKey, now, includeExpiration = false)) + } + shouldThrow { + verifier.verify(signedEvent(trustedKey, now, additionalAudience = "other-client")) + } + } + + test("event string rendering never exposes the Apple subject") { + VerifiedAppleEvent("event-1", "consent-revoked", "sensitive-apple-subject").toString() shouldBe + "VerifiedAppleEvent(eventId=event-1, type=consent-revoked, appleSubject=[REDACTED])" + } +}) + +private fun newRsaKey(keyId: String): RSAKey { + val pair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + return RSAKey.Builder(pair.public as RSAPublicKey) + .privateKey(pair.private as RSAPrivateKey) + .keyID(keyId) + .build() +} + +private fun signedEvent( + key: RSAKey, + now: Instant, + includeExpiration: Boolean = true, + additionalAudience: String? = null, +): String { + val claimsBuilder = JWTClaimsSet.Builder() + .issuer("https://appleid.apple.com") + .audience(listOfNotNull("com.example.ios", additionalAudience)) + .jwtID("event-1") + .issueTime(Date.from(now)) + .claim( + "events", + """{"type":"consent-revoked","sub":"apple-subject"}""", + ) + if (includeExpiration) { + claimsBuilder.expirationTime(Date.from(now.plusSeconds(300))) + } + val claims = claimsBuilder.build() + return SignedJWT( + JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.keyID).build(), + claims, + ).apply { + sign(RSASSASigner(key.toPrivateKey())) + }.serialize() +} diff --git a/src/test/kotlin/com/osglab/account/features/auth/AppleClientSecretProviderTest.kt b/src/test/kotlin/com/osglab/account/features/auth/AppleClientSecretProviderTest.kt new file mode 100644 index 0000000..e4336cb --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/auth/AppleClientSecretProviderTest.kt @@ -0,0 +1,74 @@ +package com.osglab.account.features.auth + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.crypto.ECDSAVerifier +import com.nimbusds.jwt.SignedJWT +import com.osglab.account.config.AppleConfig +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.security.KeyPairGenerator +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.Base64 + +class AppleClientSecretProviderTest : FunSpec({ + test("creates a verifiable short-lived ES256 Apple client secret") { + val now = Instant.parse("2026-08-16T00:00:00Z") + val keyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + val privateKeyPem = Base64.getMimeEncoder(64, "\n".toByteArray()) + .encodeToString(keyPair.private.encoded) + .let { "-----BEGIN PRIVATE KEY-----\n$it\n-----END PRIVATE KEY-----" } + val config = AppleConfig( + teamId = "TEAM123", + keyId = "KEY123", + clientId = "com.example.ios", + privateKeyPem = privateKeyPem, + jwksUrl = "https://appleid.apple.com/auth/keys", + tokenUrl = "https://appleid.apple.com/auth/token", + revokeUrl = "https://appleid.apple.com/auth/revoke", + ) + + val serialized = AppleClientSecretProvider( + config, + Clock.fixed(now, ZoneOffset.UTC), + ).create() + val jwt = SignedJWT.parse(serialized) + + jwt.header.algorithm shouldBe JWSAlgorithm.ES256 + jwt.header.keyID shouldBe "KEY123" + jwt.verify(ECDSAVerifier(keyPair.public as ECPublicKey)) shouldBe true + jwt.jwtClaimsSet.issuer shouldBe "TEAM123" + jwt.jwtClaimsSet.subject shouldBe "com.example.ios" + jwt.jwtClaimsSet.audience shouldBe listOf("https://appleid.apple.com") + jwt.jwtClaimsSet.issueTime.toInstant() shouldBe now + jwt.jwtClaimsSet.expirationTime.toInstant() shouldBe now.plusSeconds(300) + } + + test("rejects an EC key that is not Apple P-256") { + val keyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp384r1")) + }.generateKeyPair() + val privateKeyPem = Base64.getMimeEncoder(64, "\n".toByteArray()) + .encodeToString(keyPair.private.encoded) + .let { "-----BEGIN PRIVATE KEY-----\n$it\n-----END PRIVATE KEY-----" } + val config = AppleConfig( + teamId = "TEAM123", + keyId = "KEY123", + clientId = "com.example.ios", + privateKeyPem = privateKeyPem, + jwksUrl = "https://appleid.apple.com/auth/keys", + tokenUrl = "https://appleid.apple.com/auth/token", + revokeUrl = "https://appleid.apple.com/auth/revoke", + ) + + shouldThrow { + AppleClientSecretProvider(config).create() + } + } +}) diff --git a/src/test/kotlin/com/osglab/account/features/auth/AppleIdentityTokenVerifierTest.kt b/src/test/kotlin/com/osglab/account/features/auth/AppleIdentityTokenVerifierTest.kt new file mode 100644 index 0000000..3c80faf --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/auth/AppleIdentityTokenVerifierTest.kt @@ -0,0 +1,180 @@ +package com.osglab.account.features.auth + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.KeyUse +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import com.osglab.account.config.AppleConfig +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.security.MessageDigest +import java.security.KeyPairGenerator +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.Date + +class AppleIdentityTokenVerifierTest : FunSpec({ + val now = Instant.parse("2026-08-15T12:00:00Z") + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val jwk = RSAKey.Builder(keyPair.public as RSAPublicKey) + .privateKey(keyPair.private as RSAPrivateKey) + .keyID("apple-key") + .build() + val config = AppleConfig( + teamId = null, + keyId = null, + clientId = "com.example.ios", + privateKeyPem = null, + jwksUrl = "https://appleid.apple.com/auth/keys", + tokenUrl = "https://appleid.apple.com/auth/token", + revokeUrl = "https://appleid.apple.com/auth/revoke", + ) + val provider = object : AppleJwksProvider { + override suspend fun rsaKey(keyId: String): RSAKey? = + jwk.toPublicJWK().takeIf { keyId == "apple-key" } + } + val verifier = AppleIdentityTokenVerifier( + config, + provider, + Clock.fixed(now, ZoneOffset.UTC), + ) + + test("accepts a correctly signed token with matching nonce") { + val token = identityToken( + jwk, + now, + "com.example.ios", + sha256("nonce-123"), + ) + + verifier.verify(token, "nonce-123").subject shouldBe "apple-subject" + } + + test("rejects a token for another audience") { + val token = identityToken(jwk, now, "other-client", sha256("nonce-123")) + + shouldThrow { + verifier.verify(token, "nonce-123") + } + } + + test("rejects ambiguous audiences and a missing expiration") { + val ambiguousAudience = identityToken( + jwk, + now, + "com.example.ios", + sha256("nonce-123"), + additionalAudience = "other-client", + ) + val missingExpiration = identityToken( + jwk, + now, + "com.example.ios", + sha256("nonce-123"), + includeExpiration = false, + ) + + shouldThrow { + verifier.verify(ambiguousAudience, "nonce-123") + } + shouldThrow { + verifier.verify(missingExpiration, "nonce-123") + } + } + + test("rejects a JWK not designated for signature verification") { + val unsuitableKey = RSAKey.Builder(keyPair.public as RSAPublicKey) + .keyID(jwk.keyID) + .keyUse(KeyUse.ENCRYPTION) + .build() + val unsuitableVerifier = AppleIdentityTokenVerifier( + config, + object : AppleJwksProvider { + override suspend fun rsaKey(keyId: String): RSAKey? = unsuitableKey + }, + Clock.fixed(now, ZoneOffset.UTC), + ) + + shouldThrow { + unsuitableVerifier.verify( + identityToken(jwk, now, "com.example.ios", sha256("nonce-123")), + "nonce-123", + ) + } + } + + test("rejects an expired token or an untrusted issuer") { + val expired = identityToken( + jwk = jwk, + now = now.minusSeconds(600), + audience = "com.example.ios", + nonce = sha256("nonce-123"), + expiresAt = now.minusSeconds(60), + ) + val wrongIssuer = identityToken( + jwk = jwk, + now = now, + audience = "com.example.ios", + nonce = sha256("nonce-123"), + issuer = "https://attacker.example", + ) + + shouldThrow { + verifier.verify(expired, "nonce-123") + } + shouldThrow { + verifier.verify(wrongIssuer, "nonce-123") + } + } + + test("nonce verification accepts only the SHA-256 claim") { + AppleNonceVerifier.matches("nonce-123", sha256("nonce-123")) shouldBe true + AppleNonceVerifier.matches("nonce-123", "nonce-123") shouldBe false + AppleNonceVerifier.matches("nonce-123", sha256("another-nonce")) shouldBe false + } + + test("Apple identity string rendering never exposes the subject") { + AppleIdentity("sensitive-apple-subject").toString() shouldBe + "AppleIdentity(subject=[REDACTED])" + } +}) + +private fun identityToken( + jwk: RSAKey, + now: Instant, + audience: String, + nonce: String, + issuer: String = "https://appleid.apple.com", + expiresAt: Instant = now.plusSeconds(300), + additionalAudience: String? = null, + includeExpiration: Boolean = true, +): String { + val claimsBuilder = JWTClaimsSet.Builder() + .issuer(issuer) + .audience(listOfNotNull(audience, additionalAudience)) + .subject("apple-subject") + .issueTime(Date.from(now)) + .claim("nonce", nonce) + if (includeExpiration) { + claimsBuilder.expirationTime(Date.from(expiresAt)) + } + val claims = claimsBuilder.build() + return SignedJWT( + JWSHeader.Builder(JWSAlgorithm.RS256).keyID(jwk.keyID).build(), + claims, + ).apply { + sign(RSASSASigner(jwk.toPrivateKey())) + }.serialize() +} + +private fun sha256(value: String): String = + MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } diff --git a/src/test/kotlin/com/osglab/account/features/auth/AppleTokenClientTest.kt b/src/test/kotlin/com/osglab/account/features/auth/AppleTokenClientTest.kt new file mode 100644 index 0000000..e42a8fb --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/auth/AppleTokenClientTest.kt @@ -0,0 +1,123 @@ +package com.osglab.account.features.auth + +import com.osglab.account.config.AppleConfig +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.forms.FormDataContent +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import java.net.SocketTimeoutException + +class AppleTokenClientTest : FunSpec({ + test("authorization code exchange uses the replaceable HTTP boundary") { + val engine = MockEngine { request -> + request.url.toString() shouldBe "https://appleid.apple.com/auth/token" + val form = (request.body as FormDataContent).formData + form["client_id"] shouldBe "com.example.ios" + form["client_secret"] shouldBe "signed-client-secret" + form["code"] shouldBe "one-time-code" + form["grant_type"] shouldBe "authorization_code" + respond( + content = """{"refresh_token":"refresh","id_token":"identity"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val client = HttpClient(engine) { install(HttpTimeout) } + val apple = HttpAppleTokenClient( + client, + appleTokenClientConfig(), + AppleClientSecretSigner { "signed-client-secret" }, + ) + + apple.exchangeAuthorizationCode("one-time-code") shouldBe + AppleTokenExchange("refresh", "identity") + + client.close() + } + + test("provider timeout is mapped to a retryable failure") { + val engine = MockEngine { + throw SocketTimeoutException("simulated provider timeout") + } + val client = HttpClient(engine) { install(HttpTimeout) } + val apple = HttpAppleTokenClient( + httpClient = client, + config = appleTokenClientConfig(), + clientSecretProvider = AppleClientSecretSigner { "signed-client-secret" }, + requestTimeoutMillis = 10, + ) + + shouldThrow { + apple.exchangeAuthorizationCode("one-time-code") + }.retryable shouldBe true + + client.close() + } + + test("authorization code exchange rejects a response without a refresh token") { + val engine = MockEngine { + respond( + content = """{"id_token":"identity"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val client = HttpClient(engine) { install(HttpTimeout) } + val apple = HttpAppleTokenClient( + client, + appleTokenClientConfig(), + AppleClientSecretSigner { "signed-client-secret" }, + ) + + shouldThrow { + apple.exchangeAuthorizationCode("one-time-code") + }.retryable shouldBe false + + client.close() + } + + test("rate limiting is retryable without reflecting the provider response") { + val engine = MockEngine { + respond( + content = "upstream detail that must not be reflected", + status = HttpStatusCode.TooManyRequests, + ) + } + val client = HttpClient(engine) { install(HttpTimeout) } + val apple = HttpAppleTokenClient( + client, + appleTokenClientConfig(), + AppleClientSecretSigner { "signed-client-secret" }, + ) + + val failure = shouldThrow { + apple.exchangeAuthorizationCode("one-time-code") + } + failure.retryable shouldBe true + failure.message shouldBe "Apple rejected the authorization code" + + client.close() + } + + test("token exchange values are redacted from string rendering") { + AppleTokenExchange("refresh-secret", "identity-secret").toString() shouldBe + "AppleTokenExchange(refreshToken=[REDACTED], identityToken=[REDACTED])" + } +}) + +private fun appleTokenClientConfig() = AppleConfig( + teamId = "TEAM", + keyId = "KEY", + clientId = "com.example.ios", + privateKeyPem = "unused-by-test-signer", + jwksUrl = "https://appleid.apple.com/auth/keys", + tokenUrl = "https://appleid.apple.com/auth/token", + revokeUrl = "https://appleid.apple.com/auth/revoke", +) diff --git a/src/test/kotlin/com/osglab/account/features/auth/SessionAccessAuthenticatorTest.kt b/src/test/kotlin/com/osglab/account/features/auth/SessionAccessAuthenticatorTest.kt new file mode 100644 index 0000000..2601481 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/auth/SessionAccessAuthenticatorTest.kt @@ -0,0 +1,78 @@ +package com.osglab.account.features.auth + +import com.osglab.account.common.security.SessionJwt +import com.osglab.account.config.SessionConfig +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID + +class SessionAccessAuthenticatorTest : FunSpec({ + test("a signed access token is rejected immediately after its family or account is deleted") { + val now = Instant.parse("2026-08-16T00:00:00Z") + val clock = Clock.fixed(now, ZoneOffset.UTC) + val repository = MutableSessionStateRepository() + val jwt = SessionJwt( + SessionConfig( + issuer = "https://issuer.example", + audience = "ios", + hmacSecret = ByteArray(32) { 1 }, + accessMinutes = 15, + refreshDays = 30, + ), + clock, + ) + val accountId = UUID.randomUUID() + val familyId = UUID.randomUUID() + val token = jwt.issue(accountId, familyId).value + val authenticator = SessionAccessAuthenticator(jwt, repository, clock) + + authenticator.authenticate(token).shouldNotBeNull() + repository.active = false + authenticator.authenticate(token).shouldBeNull() + } +}) + +private class MutableSessionStateRepository : AuthRepository { + var active = true + + override suspend fun isSessionActive( + accountId: UUID, + sessionId: UUID, + now: Instant, + ): Boolean = active + + override suspend fun findOrCreateAccount( + identityFingerprint: String, + encryptedAppleSubject: String, + now: Instant, + ): AuthAccount = error("Not used") + + override suspend fun updateAppleRefreshToken( + accountId: UUID, + encryptedToken: String, + now: Instant, + ) = error("Not used") + + override suspend fun createSession( + accountId: UUID, + refreshTokenHash: String, + expiresAt: Instant, + now: Instant, + ): CreatedSession = error("Not used") + + override suspend fun rotateRefreshToken( + currentTokenHash: String, + newTokenHash: String, + newExpiresAt: Instant, + now: Instant, + ): RefreshRotationResult = error("Not used") + + override suspend fun revokeSessionFamily(accountId: UUID, sessionId: UUID, now: Instant): Boolean = + error("Not used") + + override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) = Unit +} diff --git a/src/test/kotlin/com/osglab/account/features/auth/SessionServiceTest.kt b/src/test/kotlin/com/osglab/account/features/auth/SessionServiceTest.kt new file mode 100644 index 0000000..ed8e750 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/auth/SessionServiceTest.kt @@ -0,0 +1,405 @@ +package com.osglab.account.features.auth + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import com.osglab.account.common.errors.TokenReuseException +import com.osglab.account.common.security.FieldEncryptor +import com.osglab.account.common.security.IdentityFingerprint +import com.osglab.account.common.security.RefreshTokenGenerator +import com.osglab.account.common.security.SessionJwt +import com.osglab.account.common.security.TokenHash +import com.osglab.account.config.AppleConfig +import com.osglab.account.config.IntegrityConfig +import com.osglab.account.config.IntegrityPolicy +import com.osglab.account.config.SessionConfig +import com.osglab.account.features.integrity.IntegrityService +import com.osglab.account.features.integrity.IntegrityEvidence +import com.osglab.account.features.integrity.UnavailableAppAttestVerifier +import com.osglab.account.features.integrity.UnavailableDeviceCheckVerifier +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.Base64 +import java.util.Date +import java.util.UUID + +class SessionServiceTest : FunSpec({ + test("refresh tokens contain 256 bits of URL-safe randomness") { + val generator = RefreshTokenGenerator() + val first = generator.newRefreshToken() + val second = generator.newRefreshToken() + + Base64.getUrlDecoder().decode(first).size shouldBe 32 + (first != second) shouldBe true + } + + test("Apple sign-in verifies both tokens and persists only protected credentials") { + val now = Instant.parse("2026-08-16T00:00:00Z") + val clock = Clock.fixed(now, ZoneOffset.UTC) + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val key = RSAKey.Builder(keyPair.public as RSAPublicKey) + .privateKey(keyPair.private as RSAPrivateKey) + .keyID("apple-key") + .build() + val nonce = "one-time-nonce" + val identityToken = signedIdentityToken(key, now, nonce) + val accountId = UUID.randomUUID() + val repository = SuccessfulAuthRepository(accountId) + val sessionConfig = sessionConfig() + val encryptor = FieldEncryptor(ByteArray(32) { 4 }) + var exchangedCode: String? = null + val service = SessionService( + repository = repository, + appleIdentityVerifier = AppleIdentityTokenVerifier( + appleConfig(), + object : AppleJwksProvider { + override suspend fun rsaKey(keyId: String): RSAKey? = + key.toPublicJWK().takeIf { keyId == key.keyID } + }, + clock, + ), + appleTokenClient = object : AppleTokenClient { + override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange { + exchangedCode = code + return AppleTokenExchange("apple-refresh", identityToken) + } + + override suspend fun revokeRefreshToken(refreshToken: String) = error("Not used") + }, + integrityService = monitorOnlyIntegrityService(), + sessionJwt = SessionJwt(sessionConfig, clock), + fieldEncryptor = encryptor, + identityFingerprint = IdentityFingerprint(ByteArray(32) { 6 }), + sessionConfig = sessionConfig, + clock = clock, + ) + + val tokens = service.signInWithApple( + identityToken = identityToken, + authorizationCode = "authorization-code", + nonce = nonce, + integrityEvidence = IntegrityEvidence(), + ) + + exchangedCode shouldBe "authorization-code" + tokens.accountId shouldBe accountId + repository.refreshTokenHash shouldBe TokenHash.sha256(tokens.refreshToken) + (repository.encryptedAppleSubject == "apple-subject") shouldBe false + (repository.encryptedAppleRefreshToken == "apple-refresh") shouldBe false + encryptor.decrypt( + requireNotNull(repository.encryptedAppleSubject), + appleSubjectContext(requireNotNull(repository.identityFingerprint)), + ) shouldBe "apple-subject" + encryptor.decrypt( + requireNotNull(repository.encryptedAppleRefreshToken), + appleRefreshContext(accountId), + ) shouldBe "apple-refresh" + SessionJwt(sessionConfig, clock).verify(tokens.accessToken)?.sessionId shouldBe + repository.sessionId + } + + test("refresh token reuse is surfaced and no replacement tokens are issued") { + val sessionConfig = sessionConfig() + val service = SessionService( + repository = ReuseDetectingRepository, + appleIdentityVerifier = AppleIdentityTokenVerifier( + appleConfig(), + object : AppleJwksProvider { + override suspend fun rsaKey(keyId: String): RSAKey? = null + }, + ), + appleTokenClient = UnavailableAppleTokenClient(), + integrityService = monitorOnlyIntegrityService(), + sessionJwt = SessionJwt(sessionConfig), + fieldEncryptor = FieldEncryptor(ByteArray(32) { 4 }), + identityFingerprint = IdentityFingerprint(ByteArray(32) { 6 }), + sessionConfig = sessionConfig, + ) + + shouldThrow { + service.refresh("already-used-token") + } + } + + test("concurrent refresh accepts once and revokes the family on replay") { + val repository = ConcurrentRotationRepository() + val sessionConfig = sessionConfig() + val service = SessionService( + repository = repository, + appleIdentityVerifier = AppleIdentityTokenVerifier( + appleConfig(), + object : AppleJwksProvider { + override suspend fun rsaKey(keyId: String): RSAKey? = null + }, + ), + appleTokenClient = UnavailableAppleTokenClient(), + integrityService = monitorOnlyIntegrityService(), + sessionJwt = SessionJwt(sessionConfig), + fieldEncryptor = FieldEncryptor(ByteArray(32) { 4 }), + identityFingerprint = IdentityFingerprint(ByteArray(32) { 6 }), + sessionConfig = sessionConfig, + ) + + val results = coroutineScope { + List(2) { + async { runCatching { service.refresh("same-refresh-token") } } + }.awaitAll() + } + + results.count { it.isSuccess } shouldBe 1 + results.count { it.exceptionOrNull() is TokenReuseException } shouldBe 1 + repository.familyRevoked shouldBe true + } + + test("refresh rotation policy rotates only an active unconsumed token") { + val now = Instant.parse("2026-08-16T00:00:00Z") + + RefreshRotationPolicy.decide( + revoked = false, + replaced = false, + expiresAt = now.plusSeconds(1), + now = now, + ) shouldBe RefreshRotationDecision.ROTATE + RefreshRotationPolicy.decide( + revoked = false, + replaced = false, + expiresAt = now, + now = now, + ) shouldBe RefreshRotationDecision.REVOKE_EXPIRED + } + + test("refresh rotation policy treats any consumed token as family reuse") { + val now = Instant.parse("2026-08-16T00:00:00Z") + + RefreshRotationPolicy.decide( + revoked = true, + replaced = false, + expiresAt = now.plusSeconds(60), + now = now, + ) shouldBe RefreshRotationDecision.REVOKE_REUSED_FAMILY + RefreshRotationPolicy.decide( + revoked = false, + replaced = true, + expiresAt = now.plusSeconds(60), + now = now, + ) shouldBe RefreshRotationDecision.REVOKE_REUSED_FAMILY + } +}) + +private class SuccessfulAuthRepository( + private val accountId: UUID, +) : AuthRepository { + val sessionId: UUID = UUID.randomUUID() + var encryptedAppleSubject: String? = null + var identityFingerprint: String? = null + var encryptedAppleRefreshToken: String? = null + var refreshTokenHash: String? = null + + override suspend fun findOrCreateAccount( + identityFingerprint: String, + encryptedAppleSubject: String, + now: Instant, + ): AuthAccount { + this.identityFingerprint = identityFingerprint + this.encryptedAppleSubject = encryptedAppleSubject + return AuthAccount(accountId, identityFingerprint, false) + } + + override suspend fun updateAppleRefreshToken( + accountId: UUID, + encryptedToken: String, + now: Instant, + ) { + encryptedAppleRefreshToken = encryptedToken + } + + override suspend fun createSession( + accountId: UUID, + refreshTokenHash: String, + expiresAt: Instant, + now: Instant, + ): CreatedSession { + this.refreshTokenHash = refreshTokenHash + return CreatedSession(accountId, sessionId, sessionId) + } + + override suspend fun rotateRefreshToken( + currentTokenHash: String, + newTokenHash: String, + newExpiresAt: Instant, + now: Instant, + ): RefreshRotationResult = error("Not used") + + override suspend fun revokeSessionFamily( + accountId: UUID, + sessionId: UUID, + now: Instant, + ): Boolean = error("Not used") + + override suspend fun isSessionActive( + accountId: UUID, + sessionId: UUID, + now: Instant, + ): Boolean = true + + override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) = Unit +} + +private data object ReuseDetectingRepository : AuthRepository { + override suspend fun findOrCreateAccount( + identityFingerprint: String, + encryptedAppleSubject: String, + now: Instant, + ): AuthAccount = + error("Not used") + + override suspend fun updateAppleRefreshToken( + accountId: UUID, + encryptedToken: String, + now: Instant, + ): Unit = error("Not used") + + override suspend fun createSession( + accountId: UUID, + refreshTokenHash: String, + expiresAt: Instant, + now: Instant, + ): CreatedSession = error("Not used") + + override suspend fun rotateRefreshToken( + currentTokenHash: String, + newTokenHash: String, + newExpiresAt: Instant, + now: Instant, + ): RefreshRotationResult = RefreshRotationResult.ReuseDetected + + override suspend fun revokeSessionFamily(accountId: UUID, sessionId: UUID, now: Instant): Boolean = + error("Not used") + + override suspend fun isSessionActive( + accountId: UUID, + sessionId: UUID, + now: Instant, + ): Boolean = false + + override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) = Unit +} + +private class ConcurrentRotationRepository : AuthRepository { + private val mutex = Mutex() + private var consumed = false + var familyRevoked = false + private set + + override suspend fun rotateRefreshToken( + currentTokenHash: String, + newTokenHash: String, + newExpiresAt: Instant, + now: Instant, + ): RefreshRotationResult = mutex.withLock { + if (consumed) { + familyRevoked = true + RefreshRotationResult.ReuseDetected + } else { + consumed = true + RefreshRotationResult.Rotated( + accountId = UUID.randomUUID(), + sessionId = UUID.randomUUID(), + familyId = UUID.randomUUID(), + ) + } + } + + override suspend fun findOrCreateAccount( + identityFingerprint: String, + encryptedAppleSubject: String, + now: Instant, + ): AuthAccount = error("Not used") + + override suspend fun updateAppleRefreshToken( + accountId: UUID, + encryptedToken: String, + now: Instant, + ) = error("Not used") + + override suspend fun createSession( + accountId: UUID, + refreshTokenHash: String, + expiresAt: Instant, + now: Instant, + ): CreatedSession = error("Not used") + + override suspend fun revokeSessionFamily( + accountId: UUID, + sessionId: UUID, + now: Instant, + ): Boolean = error("Not used") + + override suspend fun isSessionActive( + accountId: UUID, + sessionId: UUID, + now: Instant, + ): Boolean = !familyRevoked + + override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) = Unit +} + +private fun appleConfig() = AppleConfig( + teamId = null, + keyId = null, + clientId = "com.example.ios", + privateKeyPem = null, + jwksUrl = "https://appleid.apple.com/auth/keys", + tokenUrl = "https://appleid.apple.com/auth/token", + revokeUrl = "https://appleid.apple.com/auth/revoke", +) + +private fun sessionConfig() = SessionConfig( + issuer = "https://issuer.example", + audience = "ios", + hmacSecret = ByteArray(32) { 8 }, + accessMinutes = 15, + refreshDays = 30, +) + +private fun monitorOnlyIntegrityService() = IntegrityService( + IntegrityConfig(IntegrityPolicy.MONITOR, IntegrityPolicy.MONITOR), + UnavailableDeviceCheckVerifier(), + UnavailableAppAttestVerifier(), +) + +private fun signedIdentityToken(key: RSAKey, now: Instant, rawNonce: String): String { + val nonce = MessageDigest.getInstance("SHA-256") + .digest(rawNonce.toByteArray()) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + val claims = JWTClaimsSet.Builder() + .issuer("https://appleid.apple.com") + .audience("com.example.ios") + .subject("apple-subject") + .issueTime(Date.from(now)) + .expirationTime(Date.from(now.plusSeconds(300))) + .claim("nonce", nonce) + .build() + return SignedJWT( + JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.keyID).build(), + claims, + ).apply { + sign(RSASSASigner(key.toPrivateKey())) + }.serialize() +} diff --git a/src/test/kotlin/com/osglab/account/features/credits/CreditServiceTest.kt b/src/test/kotlin/com/osglab/account/features/credits/CreditServiceTest.kt new file mode 100644 index 0000000..2f3c312 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/credits/CreditServiceTest.kt @@ -0,0 +1,576 @@ +package com.osglab.account.features.credits + +import com.osglab.account.features.credits.domain.CreditCostCalculator +import com.osglab.account.features.credits.domain.CreditConflict +import com.osglab.account.features.credits.domain.CreditRateVersion +import com.osglab.account.features.credits.domain.InsufficientCredits +import com.osglab.account.features.credits.domain.InvalidCreditRequest +import com.osglab.account.features.credits.domain.LedgerEntryType +import com.osglab.account.features.credits.domain.ReservationStatus +import com.osglab.account.features.credits.domain.ReservationStateRules +import com.osglab.account.features.credits.domain.UsageKind +import com.osglab.account.features.credits.domain.UsageMeasurement +import com.osglab.account.features.credits.domain.externalIdempotencyKey +import com.osglab.account.features.credits.services.CreditService +import com.osglab.account.features.credits.services.ReferralRewardConfig +import com.osglab.account.features.referrals.domain.ReferralBinding +import com.osglab.account.features.referrals.domain.ReferralCampaign +import com.osglab.account.features.referrals.domain.ReferralCampaignBudget +import com.osglab.account.features.referrals.domain.ReferralRewardStatus +import io.kotest.core.spec.style.FunSpec +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.longs.shouldBeExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID +import kotlin.random.Random + +class CreditServiceTest : FunSpec({ + val now = Instant.parse("2026-08-15T00:00:00Z") + + test("ASR and LLM rates round each billed dimension upward") { + CreditCostCalculator.calculate( + asrRate(now), + UsageMeasurement.Asr(durationMillis = 101), + ) shouldBeExactly 2 + CreditCostCalculator.calculate( + llmRate(now), + UsageMeasurement.Llm(inputTokens = 1001, outputTokens = 1), + ) shouldBeExactly 4 + } + + test("cost calculation avoids intermediate overflow and rejects an unrepresentable result") { + CreditCostCalculator.calculate( + asrRate(now).copy( + asrCreditsNumerator = 2, + asrMillisDenominator = 2, + ), + UsageMeasurement.Asr(Long.MAX_VALUE), + ) shouldBeExactly Long.MAX_VALUE + + shouldThrow { + CreditCostCalculator.calculate( + asrRate(now).copy( + asrCreditsNumerator = 2, + asrMillisDenominator = 1, + ), + UsageMeasurement.Asr(Long.MAX_VALUE), + ) + } + } + + test("signup grant is idempotent") { + val store = storeWithRates(now) + val service = service(store, now) + val userId = UUID.randomUUID() + + service.grantSignupTrial(userId, 100, "signup-key-001") + service.grantSignupTrial(userId, 100, "signup-key-001") + + store.balance(userId) shouldBeExactly 100 + store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1 + } + + test("balance overflow rolls back without appending a ledger entry") { + val store = storeWithRates(now) + val service = service(store, now) + val userId = UUID.randomUUID() + service.grantSignupTrial(userId, Long.MAX_VALUE, "signup-max-key") + + shouldThrow { + service.grantSignupTrial(userId, 1, "signup-overflow-key") + } + + store.balance(userId) shouldBeExactly Long.MAX_VALUE + store.ledger.filter { it.userId == userId } shouldHaveSize 1 + } + + test("concurrent reservations cannot make the account negative") { + val store = storeWithRates(now) + val service = service(store, now) + val userId = UUID.randomUUID() + service.grantSignupTrial(userId, 100, "signup-key-002") + + val results = coroutineScope { + listOf("reserve-key-001", "reserve-key-002").map { key -> + async(Dispatchers.Default) { + runCatching { + service.reserve( + userId = userId, + provider = "asr-provider", + model = "asr-model", + estimatedUsage = UsageMeasurement.Asr(6_000), + managedCall = true, + idempotencyKey = key, + ) + } + } + }.awaitAll() + } + + results.count(Result<*>::isSuccess) shouldBe 1 + results.single(Result<*>::isFailure).exceptionOrNull() + .shouldBeInstanceOf() + store.balance(userId) shouldBeExactly 40 + } + + test("first positive managed settlement rewards both users exactly once") { + val store = storeWithRates(now) + val service = service(store, now) + val inviter = UUID.randomUUID() + val invitee = UUID.randomUUID() + val binding = ReferralBinding( + id = UUID.randomUUID(), + inviterUserId = inviter, + inviteeUserId = invitee, + codeId = UUID.randomUUID(), + boundAt = now, + rewardedAt = null, + rewardSettlementId = null, + ) + store.inTransaction { it.referrals.insertBindingIfAbsent(binding) } + service.grantSignupTrial(invitee, 100, "signup-key-003") + val reservation = service.reserve( + userId = invitee, + provider = "asr-provider", + model = "asr-model", + estimatedUsage = UsageMeasurement.Asr(1_000), + managedCall = true, + idempotencyKey = "reserve-key-003", + ) + + val first = service.settle( + invitee, + reservation.id, + UsageMeasurement.Asr(500), + "settle-key-003", + ) + val retried = service.settle( + invitee, + reservation.id, + UsageMeasurement.Asr(500), + "settle-key-003", + ) + + first.status shouldBe ReservationStatus.SETTLED + retried shouldBe first + store.balance(invitee) shouldBeExactly 125 + store.balance(inviter) shouldBeExactly 30 + store.ledger.filter { + it.type == LedgerEntryType.REFERRAL_INVITEE || + it.type == LedgerEntryType.REFERRAL_INVITER + } shouldHaveSize 2 + store.usageRecords shouldHaveSize 1 + store.usageRecords.single().rateVersionId shouldBe reservation.rateVersionId + store.bindings.getValue(invitee).rewardSettlementId shouldBe reservation.id + } + + test("zero-cost managed settlement does not consume first valid referral reward") { + val store = storeWithRates(now) + val service = service(store, now) + val inviter = UUID.randomUUID() + val invitee = UUID.randomUUID() + store.inTransaction { + it.referrals.insertBindingIfAbsent( + ReferralBinding( + id = UUID.randomUUID(), + inviterUserId = inviter, + inviteeUserId = invitee, + codeId = UUID.randomUUID(), + boundAt = now, + rewardedAt = null, + rewardSettlementId = null, + ), + ) + } + service.grantSignupTrial(invitee, 100, "zero-signup-key") + val zeroCost = service.reserve( + invitee, + "asr-provider", + "asr-model", + UsageMeasurement.Asr(1_000), + managedCall = true, + idempotencyKey = "zero-reserve-key", + ) + service.settle( + invitee, + zeroCost.id, + UsageMeasurement.Asr(0), + "zero-settle-key", + ) + store.bindings.getValue(invitee).rewardStatus shouldBe ReferralRewardStatus.PENDING + + val qualifying = service.reserve( + invitee, + "asr-provider", + "asr-model", + UsageMeasurement.Asr(1_000), + managedCall = true, + idempotencyKey = "valid-reserve-key", + ) + service.settle( + invitee, + qualifying.id, + UsageMeasurement.Asr(1), + "valid-settle-key", + ) + + store.bindings.getValue(invitee).rewardSettlementId shouldBe qualifying.id + store.ledger.count { + it.type == LedgerEntryType.REFERRAL_INVITER || + it.type == LedgerEntryType.REFERRAL_INVITEE + } shouldBe 2 + } + + test("concurrent settlement replay grants both referral sides only once") { + val store = storeWithRates(now) + val service = service(store, now) + val inviter = UUID.randomUUID() + val invitee = UUID.randomUUID() + store.inTransaction { + it.referrals.insertBindingIfAbsent( + ReferralBinding( + id = UUID.randomUUID(), + inviterUserId = inviter, + inviteeUserId = invitee, + codeId = UUID.randomUUID(), + boundAt = now, + rewardedAt = null, + rewardSettlementId = null, + ), + ) + } + service.grantSignupTrial(invitee, 100, "concurrent-signup-key") + val reservation = service.reserve( + userId = invitee, + provider = "asr-provider", + model = "asr-model", + estimatedUsage = UsageMeasurement.Asr(1_000), + managedCall = true, + idempotencyKey = "concurrent-reserve-key", + ) + + val results = coroutineScope { + List(8) { + async(Dispatchers.Default) { + service.settle( + userId = invitee, + reservationId = reservation.id, + actualUsage = UsageMeasurement.Asr(500), + idempotencyKey = "concurrent-settle-key", + ) + } + }.awaitAll() + } + + results.distinct() shouldHaveSize 1 + store.usageRecords shouldHaveSize 1 + store.ledger.count { + it.type == LedgerEntryType.REFERRAL_INVITEE || + it.type == LedgerEntryType.REFERRAL_INVITER + } shouldBe 2 + store.balance(inviter) shouldBeExactly 30 + store.balance(invitee) shouldBeExactly 125 + } + + test("ledger projection remains non-negative across randomized terminal operations") { + val store = storeWithRates(now) + val service = service(store, now) + val userId = UUID.randomUUID() + service.grantSignupTrial(userId, 20_000, "property-signup-key") + val random = Random(42) + + repeat(100) { index -> + val estimate = random.nextLong(1, 5_000) + val reservation = service.reserve( + userId, + "asr-provider", + "asr-model", + UsageMeasurement.Asr(estimate), + managedCall = false, + idempotencyKey = "property-reserve-$index", + ) + if (index % 3 == 0) { + service.release(userId, reservation.id, "property-release-$index") + } else { + val actual = random.nextLong(0, estimate + 1) + service.settle( + userId, + reservation.id, + UsageMeasurement.Asr(actual), + "property-settle-$index", + ) + if (index % 5 == 0) { + service.refund(userId, reservation.id, "property-refund-$index") + } + } + } + + var projection = 0L + store.ledger.filter { it.userId == userId }.forEach { entry -> + projection = Math.addExact(projection, entry.amountDelta) + entry.balanceAfter shouldBeExactly projection + (projection >= 0) shouldBe true + } + store.balance(userId) shouldBeExactly projection + } + + test("campaign cap is consumed once and later qualification is not rewarded") { + val store = storeWithRates(now) + val service = service(store, now) + val campaignId = UUID.randomUUID() + store.campaigns[campaignId] = ReferralCampaign( + id = campaignId, + name = "One reward", + startsAt = now.minusSeconds(60), + endsAt = null, + bindingWindowSeconds = 604_800, + inviterRewardCredits = 7, + inviteeRewardCredits = 5, + maxRewardedBindings = 1, + budgetCredits = 12, + enabled = true, + ) + store.campaignBudgets[campaignId] = ReferralCampaignBudget(campaignId, 0, 0, now) + val inviter = UUID.randomUUID() + val invitees = List(2) { UUID.randomUUID() } + invitees.forEach { invitee -> + store.inTransaction { + it.referrals.insertBindingIfAbsent( + ReferralBinding( + id = UUID.randomUUID(), + inviterUserId = inviter, + inviteeUserId = invitee, + codeId = UUID.randomUUID(), + boundAt = now, + rewardedAt = null, + rewardSettlementId = null, + campaignId = campaignId, + ), + ) + } + service.grantSignupTrial(invitee, 100, "campaign-signup-$invitee") + val reservation = service.reserve( + invitee, + "asr-provider", + "asr-model", + UsageMeasurement.Asr(1_000), + managedCall = true, + idempotencyKey = "campaign-reserve-$invitee", + ) + service.settle( + invitee, + reservation.id, + UsageMeasurement.Asr(500), + "campaign-settle-$invitee", + ) + } + + store.ledger.count { + it.type == LedgerEntryType.REFERRAL_INVITER || + it.type == LedgerEntryType.REFERRAL_INVITEE + } shouldBe 2 + store.campaignBudgets.getValue(campaignId).rewardedBindings shouldBeExactly 1 + store.bindings.getValue(invitees[1]).rewardStatus shouldBe + ReferralRewardStatus.INELIGIBLE_BUDGET + } + + test("exhausted campaign counters fail closed without partial rewards") { + val store = storeWithRates(now) + val service = service(store, now) + val campaignId = UUID.randomUUID() + store.campaigns[campaignId] = ReferralCampaign( + id = campaignId, + name = "Exhausted", + startsAt = now.minusSeconds(60), + endsAt = null, + bindingWindowSeconds = 604_800, + inviterRewardCredits = 7, + inviteeRewardCredits = 5, + maxRewardedBindings = null, + budgetCredits = null, + enabled = true, + ) + store.campaignBudgets[campaignId] = ReferralCampaignBudget( + campaignId, + Long.MAX_VALUE, + Long.MAX_VALUE, + now, + ) + val inviter = UUID.randomUUID() + val invitee = UUID.randomUUID() + store.inTransaction { + it.referrals.insertBindingIfAbsent( + ReferralBinding( + id = UUID.randomUUID(), + inviterUserId = inviter, + inviteeUserId = invitee, + codeId = UUID.randomUUID(), + boundAt = now, + rewardedAt = null, + rewardSettlementId = null, + campaignId = campaignId, + ), + ) + } + service.grantSignupTrial(invitee, 100, "exhausted-signup-key") + val reservation = service.reserve( + invitee, + "asr-provider", + "asr-model", + UsageMeasurement.Asr(1_000), + managedCall = true, + idempotencyKey = "exhausted-reserve-key", + ) + + service.settle( + invitee, + reservation.id, + UsageMeasurement.Asr(500), + "exhausted-settle-key", + ) + + store.bindings.getValue(invitee).rewardStatus shouldBe + ReferralRewardStatus.INELIGIBLE_BUDGET + store.ledger.none { + it.type == LedgerEntryType.REFERRAL_INVITER || + it.type == LedgerEntryType.REFERRAL_INVITEE + } shouldBe true + } + + test("release and refund restore only the corresponding debit") { + val store = storeWithRates(now) + val service = service(store, now) + val userId = UUID.randomUUID() + service.grantSignupTrial(userId, 100, "signup-key-004") + val released = service.reserve( + userId, + "asr-provider", + "asr-model", + UsageMeasurement.Asr(1_000), + managedCall = false, + idempotencyKey = "reserve-key-004", + ) + service.release(userId, released.id, "release-key-004") + service.release(userId, released.id, "release-key-004") + + val settled = service.reserve( + userId, + "asr-provider", + "asr-model", + UsageMeasurement.Asr(1_000), + managedCall = false, + idempotencyKey = "reserve-key-005", + ) + service.settle(userId, settled.id, UsageMeasurement.Asr(500), "settle-key-005") + service.refund(userId, settled.id, "refund-key-005") + service.refund(userId, settled.id, "refund-key-005") + + store.balance(userId) shouldBeExactly 100 + store.ledger.filter { it.type == LedgerEntryType.USAGE_RELEASE } shouldHaveSize 1 + store.ledger.filter { it.type == LedgerEntryType.USAGE_REFUND } shouldHaveSize 1 + } + + test("settlement replay rejects different actual usage") { + val store = storeWithRates(now) + val service = service(store, now) + val userId = UUID.randomUUID() + service.grantSignupTrial(userId, 100, "signup-key-006") + val reservation = service.reserve( + userId, + "asr-provider", + "asr-model", + UsageMeasurement.Asr(1_000), + managedCall = false, + idempotencyKey = "reserve-key-006", + ) + service.settle(userId, reservation.id, UsageMeasurement.Asr(500), "settle-key-006") + + shouldThrow { + service.settle(userId, reservation.id, UsageMeasurement.Asr(600), "settle-key-006") + } + } + + test("public idempotency values cannot occupy the internal reward namespace") { + externalIdempotencyKey("internal:referral:known-binding:invitee") shouldNotBe + "internal:referral:known-binding:invitee" + } + + test("reservation state rules allow only settle release and refund transitions") { + ReservationStateRules.canTransition( + ReservationStatus.RESERVED, + ReservationStatus.SETTLED, + ) shouldBe true + ReservationStateRules.canTransition( + ReservationStatus.RESERVED, + ReservationStatus.RELEASED, + ) shouldBe true + ReservationStateRules.canTransition( + ReservationStatus.SETTLED, + ReservationStatus.REFUNDED, + ) shouldBe true + + ReservationStatus.entries.forEach { target -> + ReservationStateRules.canTransition( + ReservationStatus.REFUNDED, + target, + ) shouldBe false + } + ReservationStateRules.canTransition( + ReservationStatus.RELEASED, + ReservationStatus.SETTLED, + ) shouldBe false + } +}) + +private fun service(store: TestBillingStore, now: Instant) = CreditService( + transactions = store, + referralRewards = ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30), + clock = Clock.fixed(now, ZoneOffset.UTC), +) + +private fun storeWithRates(now: Instant) = TestBillingStore().also { + val asr = asrRate(now) + val llm = llmRate(now) + it.rates[asr.id] = asr + it.rates[llm.id] = llm +} + +private fun asrRate(now: Instant) = CreditRateVersion( + id = UUID.nameUUIDFromBytes("asr-rate".toByteArray()), + kind = UsageKind.ASR, + provider = "asr-provider", + model = "asr-model", + effectiveFrom = now.minusSeconds(60), + effectiveUntil = null, + asrCreditsNumerator = 1, + asrMillisDenominator = 100, + inputCreditsNumerator = null, + inputTokensDenominator = null, + outputCreditsNumerator = null, + outputTokensDenominator = null, +) + +private fun llmRate(now: Instant) = CreditRateVersion( + id = UUID.nameUUIDFromBytes("llm-rate".toByteArray()), + kind = UsageKind.LLM, + provider = "llm-provider", + model = "llm-model", + effectiveFrom = now.minusSeconds(60), + effectiveUntil = null, + asrCreditsNumerator = null, + asrMillisDenominator = null, + inputCreditsNumerator = 2, + inputTokensDenominator = 1_000, + outputCreditsNumerator = 3, + outputTokensDenominator = 1_000, +) diff --git a/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt b/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt new file mode 100644 index 0000000..00e559a --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt @@ -0,0 +1,244 @@ +package com.osglab.account.features.credits + +import com.osglab.account.features.credits.domain.CreditAccount +import com.osglab.account.features.credits.domain.CreditNotFound +import com.osglab.account.features.credits.domain.CreditRateVersion +import com.osglab.account.features.credits.domain.CreditReservation +import com.osglab.account.features.credits.domain.CreditUsageRecord +import com.osglab.account.features.credits.domain.LedgerEntry +import com.osglab.account.features.credits.domain.UsageKind +import com.osglab.account.features.credits.repositories.BillingTransactionRunner +import com.osglab.account.features.credits.repositories.BillingUnitOfWork +import com.osglab.account.features.credits.repositories.CreditsRepository +import com.osglab.account.features.referrals.domain.ReferralBinding +import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID +import com.osglab.account.features.referrals.domain.ReferralCampaign +import com.osglab.account.features.referrals.domain.ReferralCampaignBudget +import com.osglab.account.features.referrals.domain.ReferralCode +import com.osglab.account.features.referrals.domain.ReferralRewardStatus +import com.osglab.account.features.referrals.repositories.ReferralsRepository +import java.time.Instant +import java.util.UUID +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork { + private val lock = ReentrantLock() + private val accounts = mutableMapOf() + val ledger = mutableListOf() + val usageRecords = mutableListOf() + val reservations = mutableMapOf() + val rates = mutableMapOf() + val codes = mutableMapOf() + val bindings = mutableMapOf() + val campaigns = mutableMapOf( + DEFAULT_REFERRAL_CAMPAIGN_ID to ReferralCampaign( + id = DEFAULT_REFERRAL_CAMPAIGN_ID, + name = "Default", + startsAt = Instant.EPOCH, + endsAt = null, + bindingWindowSeconds = 7 * 24 * 60 * 60, + inviterRewardCredits = 30, + inviteeRewardCredits = 30, + maxRewardedBindings = null, + budgetCredits = null, + enabled = true, + ), + ) + val campaignBudgets = mutableMapOf( + DEFAULT_REFERRAL_CAMPAIGN_ID to ReferralCampaignBudget( + campaignId = DEFAULT_REFERRAL_CAMPAIGN_ID, + rewardedBindings = 0, + spentCredits = 0, + updatedAt = Instant.EPOCH, + ), + ) + + override val credits: CreditsRepository = Credits() + override val referrals: ReferralsRepository = Referrals() + + override suspend fun inTransaction(block: (BillingUnitOfWork) -> T): T = + lock.withLock { + val accountSnapshot = accounts.toMap() + val ledgerSnapshot = ledger.toList() + val usageSnapshot = usageRecords.toList() + val reservationSnapshot = reservations.toMap() + val codeSnapshot = codes.toMap() + val bindingSnapshot = bindings.toMap() + val budgetSnapshot = campaignBudgets.toMap() + try { + block(this) + } catch (failure: Throwable) { + accounts.replaceWith(accountSnapshot) + ledger.replaceWith(ledgerSnapshot) + usageRecords.replaceWith(usageSnapshot) + reservations.replaceWith(reservationSnapshot) + codes.replaceWith(codeSnapshot) + bindings.replaceWith(bindingSnapshot) + campaignBudgets.replaceWith(budgetSnapshot) + throw failure + } + } + + fun balance(userId: UUID): Long = lock.withLock { accounts[userId]?.balance ?: 0 } + + private inner class Credits : CreditsRepository { + override fun createAccountIfAbsent(userId: UUID, now: Instant) { + accounts.putIfAbsent(userId, CreditAccount(userId, 0, now)) + } + + override fun lockAccount(userId: UUID): CreditAccount = + accounts[userId] ?: throw CreditNotFound("Credit account does not exist") + + override fun updateAccountBalance( + userId: UUID, + newBalance: Long, + now: Instant, + ): CreditAccount = CreditAccount(userId, newBalance, now).also { accounts[userId] = it } + + override fun findLedgerEntry(userId: UUID, idempotencyKey: String): LedgerEntry? = + ledger.singleOrNull { + it.userId == userId && it.idempotencyKey == idempotencyKey + } + + override fun insertLedgerEntry(entry: LedgerEntry) { + check(findLedgerEntry(entry.userId, entry.idempotencyKey) == null) + ledger += entry + } + + override fun listLedgerEntries(userId: UUID, limit: Int): List = + ledger.filter { it.userId == userId } + .sortedWith(compareByDescending { it.createdAt }.thenByDescending { it.id }) + .take(limit) + + override fun insertUsageRecord(record: CreditUsageRecord) { + check(usageRecords.none { it.reservationId == record.reservationId }) + usageRecords += record + } + + override fun findReservationByReserveKey( + userId: UUID, + idempotencyKey: String, + ): CreditReservation? = reservations.values.singleOrNull { + it.userId == userId && it.reserveIdempotencyKey == idempotencyKey + } + + override fun lockReservation(id: UUID): CreditReservation? = reservations[id] + + override fun insertReservation(reservation: CreditReservation) { + check(reservations.putIfAbsent(reservation.id, reservation) == null) + } + + override fun updateReservation(reservation: CreditReservation) { + check(reservations.containsKey(reservation.id)) + reservations[reservation.id] = reservation + } + + override fun findRateVersion(id: UUID): CreditRateVersion? = rates[id] + + override fun findEffectiveRate( + kind: UsageKind, + provider: String, + model: String, + at: Instant, + ): CreditRateVersion? = rates.values + .filter { + it.kind == kind && + it.provider == provider && + it.model == model && + it.effectiveFrom <= at && + (it.effectiveUntil == null || it.effectiveUntil > at) + } + .maxByOrNull(CreditRateVersion::effectiveFrom) + + override fun listEffectiveRates(at: Instant): List = + rates.values.filter { + it.effectiveFrom <= at && (it.effectiveUntil == null || it.effectiveUntil > at) + } + } + + private inner class Referrals : ReferralsRepository { + override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? = + codes.values + .filter { it.ownerUserId == ownerUserId } + .filter { campaignId == null || it.campaignId == campaignId } + .maxByOrNull(ReferralCode::createdAt) + + override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? = + findCodeByOwner(ownerUserId, campaignId) + + override fun findCode(code: String): ReferralCode? = + codes.values.singleOrNull { it.code == code } + + override fun insertCodeIfAbsent(code: ReferralCode): Boolean { + if (findCodeByOwner(code.ownerUserId, code.campaignId) != null || + findCode(code.code) != null + ) { + return false + } + codes[code.id] = code + return true + } + + override fun findCampaign(id: UUID): ReferralCampaign? = campaigns[id] + + override fun listActiveCampaigns(at: Instant): List = + campaigns.values.filter { it.isActive(at) }.sortedByDescending { it.startsAt } + + override fun lockCampaignBudget(campaignId: UUID): ReferralCampaignBudget = + campaignBudgets.getValue(campaignId) + + override fun updateCampaignBudget(budget: ReferralCampaignBudget) { + campaignBudgets[budget.campaignId] = budget + } + + override fun findBinding(inviteeUserId: UUID): ReferralBinding? = bindings[inviteeUserId] + + override fun listBindingsByInviter( + inviterUserId: UUID, + limit: Int, + ): List = + bindings.values + .filter { it.inviterUserId == inviterUserId } + .sortedByDescending { it.boundAt } + .take(limit) + + override fun lockBinding(inviteeUserId: UUID): ReferralBinding? = bindings[inviteeUserId] + + override fun insertBindingIfAbsent(binding: ReferralBinding): Boolean { + if (bindings.containsKey(binding.inviteeUserId)) return false + bindings[binding.inviteeUserId] = binding + return true + } + + override fun markRewarded( + bindingId: UUID, + settlementId: UUID, + rewardedAt: Instant, + ) { + val entry = bindings.entries.single { it.value.id == bindingId } + entry.setValue( + entry.value.copy( + rewardedAt = rewardedAt, + rewardSettlementId = settlementId, + rewardStatus = ReferralRewardStatus.REWARDED, + ), + ) + } + + override fun markRewardIneligible(bindingId: UUID) { + val entry = bindings.entries.single { it.value.id == bindingId } + entry.setValue(entry.value.copy(rewardStatus = ReferralRewardStatus.INELIGIBLE_BUDGET)) + } + } +} + +private fun MutableMap.replaceWith(snapshot: Map) { + clear() + putAll(snapshot) +} + +private fun MutableList.replaceWith(snapshot: List) { + clear() + addAll(snapshot) +} diff --git a/src/test/kotlin/com/osglab/account/features/gateway/asr/AsrStreamingServiceTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/asr/AsrStreamingServiceTest.kt new file mode 100644 index 0000000..6e90c44 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/asr/AsrStreamingServiceTest.kt @@ -0,0 +1,359 @@ +package com.osglab.account.features.gateway.asr + +import com.osglab.account.features.gateway.models.GatewayCapability +import com.osglab.account.features.gateway.models.GatewayLimits +import com.osglab.account.features.gateway.models.GatewayPrincipal +import com.osglab.account.features.gateway.models.ProviderDescriptor +import com.osglab.account.features.gateway.models.ProviderOutput +import com.osglab.account.features.gateway.models.ProviderRequest +import com.osglab.account.features.gateway.models.ProviderUsage +import com.osglab.account.features.gateway.models.UsageMeter +import com.osglab.account.features.gateway.ports.CreditMeterPort +import com.osglab.account.features.gateway.ports.CreditReservation +import com.osglab.account.features.gateway.ports.GatewayUsagePort +import com.osglab.account.features.gateway.ports.PendingSettlement +import com.osglab.account.features.gateway.ports.ProviderRequestMetadata +import com.osglab.account.features.gateway.providers.volcengine.AsrTransportResult +import com.osglab.account.features.gateway.providers.volcengine.VolcengineStreamingClient +import com.osglab.account.features.gateway.providers.GatewayProvider +import com.osglab.account.features.gateway.providers.ProviderCatalog +import com.osglab.account.features.gateway.services.GatewayService +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch + +class AsrStreamingServiceTest : StringSpec({ + "releases credits when the mock upstream fails" { + val fixture = fixture( + upstream = VolcengineStreamingClient { _, _, _ -> throw MockUpstreamFailure() }, + ) + try { + val session = fixture.service.createSession(PRINCIPAL, "request-asr-1", request()) + + shouldThrow { + fixture.service.stream( + session.sessionId, + PRINCIPAL, + flowOf(byteArrayOf(1, 2)), + DISCARD_OUTPUT, + ) + } + + fixture.credits.released.shouldContainExactly(RESERVATION_ID) + fixture.credits.settled shouldBe emptyList() + } finally { + fixture.scope.cancel() + } + } + + "releases credits when ASR completes without a result" { + val fixture = fixture( + upstream = VolcengineStreamingClient { _, frames, _ -> + frames.collect { } + AsrTransportResult(700, "provider-1", hasResult = false) + }, + ) + try { + val session = fixture.service.createSession(PRINCIPAL, "request-asr-2", request()) + + shouldThrow { + fixture.service.stream( + session.sessionId, + PRINCIPAL, + flowOf(byteArrayOf(1)), + DISCARD_OUTPUT, + ) + } + + fixture.credits.released.shouldContainExactly(RESERVATION_ID) + fixture.usage.manualReview shouldBe false + } finally { + fixture.scope.cancel() + } + } + + "settles successful ASR by provider milliseconds" { + val fixture = fixture( + upstream = VolcengineStreamingClient { _, frames, _ -> + frames.collect { } + AsrTransportResult(725, "provider-2", hasResult = true) + }, + ) + try { + val session = fixture.service.createSession(PRINCIPAL, "request-asr-3", request()) + fixture.service.stream( + session.sessionId, + PRINCIPAL, + flowOf(byteArrayOf(1, 2, 3)), + DISCARD_OUTPUT, + ) + + fixture.credits.settled.shouldContainExactly(RESERVATION_ID to 725L) + fixture.credits.released shouldBe emptyList() + } finally { + fixture.scope.cancel() + } + } + + "binds a streaming session to the grant that reserved it" { + val fixture = fixture( + upstream = VolcengineStreamingClient { _, frames, _ -> + frames.collect { } + AsrTransportResult(500, "provider-grant") + }, + ) + try { + val session = fixture.service.createSession(PRINCIPAL, "request-asr-grant", request()) + + shouldThrow { + fixture.service.stream( + session.sessionId, + PRINCIPAL.copy(grantId = "other-grant"), + flowOf(byteArrayOf(1)), + DISCARD_OUTPUT, + ) + } + fixture.service.stream( + session.sessionId, + PRINCIPAL, + flowOf(byteArrayOf(1)), + DISCARD_OUTPUT, + ) + + fixture.credits.settled.shouldContainExactly(RESERVATION_ID to 500L) + } finally { + fixture.scope.cancel() + } + } + + "rejects oversized audio frames before forwarding them" { + var upstreamFrames = 0 + val fixture = fixture( + upstream = VolcengineStreamingClient { _, frames, _ -> + frames.collect { upstreamFrames += 1 } + AsrTransportResult(500, "provider-3") + }, + limits = AsrStreamingLimits(maxFrameBytes = 2), + ) + try { + val session = fixture.service.createSession(PRINCIPAL, "request-asr-4", request()) + + shouldThrow { + fixture.service.stream( + session.sessionId, + PRINCIPAL, + flowOf(byteArrayOf(1, 2, 3)), + DISCARD_OUTPUT, + ) + } + + upstreamFrames shouldBe 0 + fixture.credits.released.shouldContainExactly(RESERVATION_ID) + } finally { + fixture.scope.cancel() + } + } + + "does not allow a large frame to exceed declared PCM duration" { + var upstreamFrames = 0 + val fixture = fixture( + upstream = VolcengineStreamingClient { _, frames, _ -> + frames.collect { upstreamFrames += 1 } + AsrTransportResult(1, "provider-duration") + }, + ) + try { + val session = fixture.service.createSession( + PRINCIPAL, + "request-asr-duration", + request().copy(estimatedDurationMillis = 1), + ) + + shouldThrow { + fixture.service.stream( + session.sessionId, + PRINCIPAL, + flowOf(ByteArray(33)), + DISCARD_OUTPUT, + ) + } + + upstreamFrames shouldBe 0 + fixture.credits.released.shouldContainExactly(RESERVATION_ID) + } finally { + fixture.scope.cancel() + } + } + + "reserves the full policy duration for compressed streaming audio" { + val fixture = fixture( + upstream = VolcengineStreamingClient { _, frames, _ -> + frames.collect { } + AsrTransportResult(500, "provider-compressed") + }, + ) + try { + fixture.service.createSession( + PRINCIPAL, + "request-asr-compressed", + request().copy(format = "mp3", codec = "raw"), + ) + + fixture.credits.lastEstimatedUnits shouldBe GatewayLimits.MAX_AUDIO_MILLIS + } finally { + fixture.scope.cancel() + } + } + + "releases credits when a streaming ASR call is cancelled" { + val started = CompletableDeferred() + val fixture = fixture( + upstream = VolcengineStreamingClient { _, _, _ -> + started.complete(Unit) + awaitCancellation() + }, + ) + try { + val session = fixture.service.createSession(PRINCIPAL, "request-asr-5", request()) + + coroutineScope { + val call = launch { + fixture.service.stream( + session.sessionId, + PRINCIPAL, + flowOf(byteArrayOf(1, 2, 3)), + DISCARD_OUTPUT, + ) + } + started.await() + call.cancelAndJoin() + } + + fixture.credits.released.shouldContainExactly(RESERVATION_ID) + fixture.credits.settled shouldBe emptyList() + } finally { + fixture.scope.cancel() + } + } +}) + +private data class Fixture( + val service: AsrStreamingService, + val credits: FakeAsrCredits, + val usage: FakeGatewayUsage, + val scope: CoroutineScope, +) + +private fun fixture( + upstream: VolcengineStreamingClient, + limits: AsrStreamingLimits = AsrStreamingLimits(), +): Fixture { + val credits = FakeAsrCredits() + val usage = FakeGatewayUsage() + val gateway = GatewayService( + catalog = ProviderCatalog(listOf(DummyAsrProvider)), + credits = credits, + grants = { _, _ -> true }, + usageRecords = usage, + ) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + return Fixture( + AsrStreamingService(gateway, upstream, scope, limits), + credits, + usage, + scope, + ) +} + +private fun request() = CreateAsrSessionRequest(estimatedDurationMillis = 1_000) + +private class FakeAsrCredits : CreditMeterPort { + val settled = mutableListOf>() + val released = mutableListOf() + var lastEstimatedUnits: Long? = null + + override suspend fun reserve( + accountId: String, + meter: UsageMeter, + estimatedUnits: Long, + requestId: String, + ): CreditReservation { + lastEstimatedUnits = estimatedUnits + return CreditReservation(RESERVATION_ID, estimatedUnits) + } + + override suspend fun settle(reservationId: String, actualUnits: Long) { + settled += reservationId to actualUnits + } + + override suspend fun settle(reservationId: String, usage: ProviderUsage) { + settle(reservationId, usage.units) + } + + override suspend fun release(reservationId: String) { + released += reservationId + } +} + +private class FakeGatewayUsage : GatewayUsagePort { + var manualReview = false + + override suspend fun claim(metadata: ProviderRequestMetadata) = Unit + override suspend fun markStarted(accountId: String, requestId: String) = Unit + override suspend fun markSettlementPending( + accountId: String, + requestId: String, + usage: ProviderUsage, + ) = Unit + + override suspend fun markSucceeded( + accountId: String, + requestId: String, + usage: ProviderUsage, + ) = Unit + + override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) = Unit + + override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) { + manualReview = true + } + + override suspend fun findSettlementPending(limit: Int): List = emptyList() +} + +private object DummyAsrProvider : GatewayProvider { + override val descriptor = ProviderDescriptor( + id = "test-asr", + capabilities = setOf(GatewayCapability.ASR), + streaming = true, + usageMeter = UsageMeter.AUDIO_MILLISECOND, + ) + + override fun accepts(request: ProviderRequest): Boolean = + request.capability == GatewayCapability.ASR + + override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage = + error("Streaming tests call the upstream transport directly") +} + +private val PRINCIPAL = GatewayPrincipal( + userId = "user-1", + grantId = "grant-1", + scopes = setOf(GatewayCapability.ASR), +) +private val DISCARD_OUTPUT = ProviderOutput { } +private const val RESERVATION_ID = "reservation-asr" + +private class MockUpstreamFailure : RuntimeException() diff --git a/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt new file mode 100644 index 0000000..db4232b --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt @@ -0,0 +1,45 @@ +package com.osglab.account.features.gateway.models + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec + +class TextRequestPolicyTest : StringSpec({ + "rejects blank and oversized input" { + shouldThrow { + TextRequestPolicy.validate(TextGatewayRequest(input = " ")) + } + shouldThrow { + TextRequestPolicy.validate( + TextGatewayRequest(input = "a".repeat(GatewayLimits.MAX_TEXT_INPUT_CHARS + 1)), + ) + } + } + + "rejects oversized context and output settings" { + shouldThrow { + TextRequestPolicy.validate( + TextGatewayRequest( + input = "hello", + context = "a".repeat(GatewayLimits.MAX_TEXT_CONTEXT_CHARS + 1), + ), + ) + } + shouldThrow { + TextRequestPolicy.validate( + TextGatewayRequest( + input = "hello", + maxOutputTokens = GatewayLimits.MAX_OUTPUT_TOKENS + 1, + ), + ) + } + } + + "requires agent responses to be buffered for schema validation" { + shouldThrow { + TextRequestPolicy.validate( + TextGatewayRequest(input = "plan this", stream = true), + GatewayCapability.AGENT, + ) + } + } +}) diff --git a/src/test/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekClientTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekClientTest.kt new file mode 100644 index 0000000..a098786 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekClientTest.kt @@ -0,0 +1,183 @@ +package com.osglab.account.features.gateway.providers.deepseek + +import com.osglab.account.features.gateway.models.GatewayCapability +import com.osglab.account.features.gateway.models.ProviderOutput +import com.osglab.account.features.gateway.models.TextProviderRequest +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +class DeepSeekClientTest : StringSpec({ + "prefers provider token usage" { + val client = client( + """{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}}""", + ) + try { + val usage = KtorDeepSeekClient(client, CONFIG).complete(request(), DISCARD_OUTPUT) + + usage.units shouldBe 13L + usage.inputUnits shouldBe 10L + usage.outputUnits shouldBe 3L + } finally { + client.close() + } + } + + "fails closed when usage is absent" { + val client = client("""{"choices":[{"message":{"content":"ok"}}]}""") + var emitted = false + try { + shouldThrow { + KtorDeepSeekClient(client, CONFIG).complete( + request(), + ProviderOutput { emitted = true }, + ) + } + emitted shouldBe false + } finally { + client.close() + } + } + + "meters provider input and output tokens from a streamed response" { + val response = """ + data: {"choices":[{"delta":{"content":"ok"}}]} + + data: {"choices":[{"delta":{},"finish_reason":"stop"}]} + + data: {"choices":[],"usage":{"prompt_tokens":11,"completion_tokens":4,"total_tokens":15}} + + data: [DONE] + + """.trimIndent() + val client = client(response, ContentType.Text.EventStream) + val emitted = mutableListOf() + try { + val usage = KtorDeepSeekClient(client, CONFIG).complete( + request().copy(stream = true), + ProviderOutput { bytes -> emitted += bytes }, + ) + + usage.units shouldBe 15L + usage.inputUnits shouldBe 11L + usage.outputUnits shouldBe 4L + emitted.isNotEmpty() shouldBe true + } finally { + client.close() + } + } + + "rejects a stream that closes without the DONE marker" { + val response = """ + data: {"choices":[{"delta":{"content":"partial"}}]} + + data: {"choices":[{"delta":{},"finish_reason":"stop"}]} + + data: {"choices":[],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}} + + """.trimIndent() + val client = client(response, ContentType.Text.EventStream) + try { + shouldThrow { + KtorDeepSeekClient(client, CONFIG).complete( + request().copy(stream = true), + DISCARD_OUTPUT, + ) + } + } finally { + client.close() + } + } + + "does not forward malformed provider SSE data" { + val client = client("data: not-json\n\n", ContentType.Text.EventStream) + var emitted = false + try { + shouldThrow { + KtorDeepSeekClient(client, CONFIG).complete( + request().copy(stream = true), + ProviderOutput { emitted = true }, + ) + } + emitted shouldBe false + } finally { + client.close() + } + } + + "rejects a successful response with the wrong content type before forwarding" { + val client = client("upstream error", ContentType.Text.Html) + var emitted = false + try { + shouldThrow { + KtorDeepSeekClient(client, CONFIG).complete( + request(), + ProviderOutput { emitted = true }, + ) + } + emitted shouldBe false + } finally { + client.close() + } + } + + "rejects an unstructured agent response before forwarding it" { + val client = client("""{"choices":[{"message":{"content":"not-json"}}]}""") + var emitted = false + try { + shouldThrow { + KtorDeepSeekClient(client, CONFIG).complete( + request(capability = GatewayCapability.AGENT), + ProviderOutput { emitted = true }, + ) + } + emitted shouldBe false + } finally { + client.close() + } + } +}) + +private fun client( + responseBody: String, + contentType: ContentType = ContentType.Application.Json, +) = HttpClient( + MockEngine { + respond( + content = responseBody, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, contentType.toString()), + ) + }, +) { + install(ContentNegotiation) { + json(Json { explicitNulls = false }) + } +} + +private fun request(capability: GatewayCapability = GatewayCapability.AI) = TextProviderRequest( + requestId = "deepseek-request", + capability = capability, + input = "hello", + context = null, + maxOutputTokens = 32, + temperature = 0.2, + stream = false, +) + +private val CONFIG = DeepSeekConfig( + endpoint = "https://api.deepseek.com/v1", + apiKey = "test-key", + model = "configured-model", +) +private val DISCARD_OUTPUT = ProviderOutput { } diff --git a/src/test/kotlin/com/osglab/account/features/gateway/providers/volcengine/SaucV3ProtocolTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/providers/volcengine/SaucV3ProtocolTest.kt new file mode 100644 index 0000000..2f758fc --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/providers/volcengine/SaucV3ProtocolTest.kt @@ -0,0 +1,144 @@ +package com.osglab.account.features.gateway.providers.volcengine + +import com.osglab.account.features.gateway.models.AsrGatewayOptions +import com.osglab.account.features.gateway.models.AudioDurationPolicy +import com.osglab.account.features.gateway.models.GatewayLimits +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.io.ByteArrayInputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.zip.GZIPInputStream + +class SaucV3ProtocolTest : StringSpec({ + "encodes v3 full and final audio requests with the documented v1 binary header" { + val codec = SaucV3Codec() + val fullPayload = """{"audio":{"format":"pcm"}}""".encodeToByteArray() + val full = codec.fullClientRequest(fullPayload) + val finalAudio = codec.audioRequest(byteArrayOf(1, 2, 3), isLast = true) + + (full[0].toInt() and 0xff) shouldBe 0x11 + (full[1].toInt() and 0xff) shouldBe 0x10 + (full[2].toInt() and 0xff) shouldBe 0x11 + inflatePayload(full) shouldBe fullPayload + (finalAudio[0].toInt() and 0xff) shouldBe 0x11 + (finalAudio[1].toInt() and 0xff) shouldBe 0x22 + (finalAudio[2].toInt() and 0xff) shouldBe 0x01 + inflatePayload(finalAudio) shouldBe byteArrayOf(1, 2, 3) + } + + "accepts strictly increasing positive sequences and a negative final sequence" { + val codec = SaucV3Codec() + val validator = SaucSequenceValidator() + + validator.accept(codec.decodeServerFrame(serverFrame(1, false, """{"result":{}}"""))) + val final = codec.decodeServerFrame( + serverFrame(-2, true, """{"audio_info":{"duration":1234}}"""), + ) + validator.accept(final) + + extractFinalDuration(final) shouldBe 1_234L + } + + "rejects a positive final sequence" { + val frame = SaucV3Codec().decodeServerFrame( + serverFrame(1, true, """{"audio_info":{"duration":100}}"""), + ) + + shouldThrow { + SaucSequenceValidator().accept(frame) + } + } + + "rejects out of order server sequences" { + val codec = SaucV3Codec() + val validator = SaucSequenceValidator() + validator.accept(codec.decodeServerFrame(serverFrame(1, false, "{}"))) + + shouldThrow { + validator.accept(codec.decodeServerFrame(serverFrame(3, false, "{}"))) + } + } + + "only the final frame can provide billable duration" { + val nonFinal = SaucV3Codec().decodeServerFrame( + serverFrame(1, false, """{"audio_info":{"duration":1}}"""), + ) + + shouldThrow { + extractFinalDuration(nonFinal) + } + } + + "final duration is required and bounded" { + val codec = SaucV3Codec() + shouldThrow { + extractFinalDuration(codec.decodeServerFrame(serverFrame(-1, true, "{}"))) + } + shouldThrow { + extractFinalDuration( + codec.decodeServerFrame( + serverFrame( + -1, + true, + """{"audio_info":{"duration":${GatewayLimits.MAX_AUDIO_MILLIS + 1}}}""", + ), + ), + ) + } + } + + "requires actual recognized text before billing ASR output" { + hasRecognitionResult("""{"result":{"text":"hello"}}""".encodeToByteArray()) shouldBe true + hasRecognitionResult( + """{"result":{"utterances":[{"text":"hello"}]}}""".encodeToByteArray(), + ) shouldBe true + hasRecognitionResult("""{"result":{"text":"","utterances":[]}}""".encodeToByteArray()) shouldBe false + hasRecognitionResult("""{"result":{"definite":true}}""".encodeToByteArray()) shouldBe false + } + + "PCM reservation is derived from bytes and rejects a forged short duration" { + val oneSecondPcmBytes = 16_000 * 2 + val forged = AsrGatewayOptions(estimatedDurationMillis = 1) + + shouldThrow { + AudioDurationPolicy.reservationMillis(oneSecondPcmBytes, forged) + } + + AudioDurationPolicy.reservationMillis( + oneSecondPcmBytes, + forged.copy(estimatedDurationMillis = 1_000), + ) shouldBe 1_000L + } + + "compressed audio reserves the full policy boundary" { + val options = AsrGatewayOptions( + format = "ogg", + codec = "opus", + estimatedDurationMillis = 20_000, + ) + + AudioDurationPolicy.reservationMillis(64_000, options) shouldBe + GatewayLimits.MAX_AUDIO_MILLIS + } +}) + +private fun serverFrame(sequence: Int, final: Boolean, payload: String): ByteArray { + val bytes = payload.encodeToByteArray() + return ByteBuffer.allocate(12 + bytes.size) + .order(ByteOrder.BIG_ENDIAN) + .put(0x11) + .put(if (final) 0x93.toByte() else 0x91.toByte()) + .put(0x10) + .put(0) + .putInt(sequence) + .putInt(bytes.size) + .put(bytes) + .array() +} + +private fun inflatePayload(frame: ByteArray): ByteArray { + val size = ByteBuffer.wrap(frame, 4, 4).order(ByteOrder.BIG_ENDIAN).int + return GZIPInputStream(ByteArrayInputStream(frame, 8, size)).use { it.readAllBytes() } +} diff --git a/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt new file mode 100644 index 0000000..be9d2ac --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt @@ -0,0 +1,158 @@ +package com.osglab.account.features.gateway.routes + +import com.osglab.account.features.gateway.models.GatewayCapability +import com.osglab.account.features.gateway.models.GatewayPrincipal +import com.osglab.account.features.gateway.models.ProviderDescriptor +import com.osglab.account.features.gateway.models.ProviderOutput +import com.osglab.account.features.gateway.models.ProviderRequest +import com.osglab.account.features.gateway.models.ProviderUsage +import com.osglab.account.features.gateway.models.UsageMeter +import com.osglab.account.features.gateway.ports.CreditReservation +import com.osglab.account.features.gateway.ports.CreditReservationPort +import com.osglab.account.features.gateway.ports.GatewayUsagePort +import com.osglab.account.features.gateway.ports.PendingSettlement +import com.osglab.account.features.gateway.ports.ProviderRequestMetadata +import com.osglab.account.features.gateway.providers.GatewayProvider +import com.osglab.account.features.gateway.providers.ProviderCatalog +import com.osglab.account.features.gateway.services.GatewayService +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import kotlinx.serialization.json.Json + +class GatewayRequestIdTest : StringSpec({ + "requires X-Request-ID before invoking a billable provider" { + val provider = RequestIdProvider() + + testApplication { + application { gatewayTestApplication(provider) } + + val missing = client.post("/v1/gateway/llm/ai") { + contentType(ContentType.Application.Json) + setBody("""{"input":"hello","maxOutputTokens":8}""") + } + val invalid = client.post("/v1/gateway/llm/ai") { + header("X-Request-ID", "bad") + contentType(ContentType.Application.Json) + setBody("""{"input":"hello","maxOutputTokens":8}""") + } + + missing.status shouldBe HttpStatusCode.BadRequest + invalid.status shouldBe HttpStatusCode.BadRequest + provider.calls shouldBe 0 + } + } + + "uses X-Request-ID as the account-scoped provider idempotency key" { + val provider = RequestIdProvider() + + testApplication { + application { gatewayTestApplication(provider) } + + val response = client.post("/v1/gateway/llm/ai") { + header("X-Request-ID", "request-route-123") + contentType(ContentType.Application.Json) + setBody("""{"input":"hello","maxOutputTokens":8}""") + } + + response.status shouldBe HttpStatusCode.OK + provider.lastRequestId shouldBe "request-route-123" + } + } +}) + +private fun io.ktor.server.application.Application.gatewayTestApplication(provider: RequestIdProvider) { + install(ContentNegotiation) { + json(Json { explicitNulls = false }) + } + val service = GatewayService( + catalog = ProviderCatalog(listOf(provider)), + credits = RequestIdCredits, + grants = { _, _ -> true }, + usageRecords = RequestIdUsage, + ) + routing { + configureGatewayRoutes( + service = service, + appIdentity = { null }, + gatewayIdentity = { REQUEST_ID_PRINCIPAL }, + ) + } +} + +private class RequestIdProvider : GatewayProvider { + var calls = 0 + var lastRequestId: String? = null + + override val descriptor = ProviderDescriptor( + id = "request-id-provider", + capabilities = setOf(GatewayCapability.AI), + streaming = true, + usageMeter = UsageMeter.LLM_TOKEN, + ) + + override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage { + calls += 1 + lastRequestId = request.requestId + output.emit("""{"result":"ok"}""".encodeToByteArray()) + return ProviderUsage( + meter = UsageMeter.LLM_TOKEN, + units = 3, + inputUnits = 2, + outputUnits = 1, + ) + } +} + +private object RequestIdCredits : CreditReservationPort { + override suspend fun reserve( + accountId: String, + meter: UsageMeter, + estimatedUnits: Long, + requestId: String, + ) = CreditReservation("00000000-0000-0000-0000-000000000002", estimatedUnits) + + override suspend fun settle(reservationId: String, actualUnits: Long) = Unit + + override suspend fun release(reservationId: String) = Unit +} + +private object RequestIdUsage : GatewayUsagePort { + override suspend fun claim(metadata: ProviderRequestMetadata) = Unit + + override suspend fun markStarted(accountId: String, requestId: String) = Unit + + override suspend fun markSettlementPending( + accountId: String, + requestId: String, + usage: ProviderUsage, + ) = Unit + + override suspend fun markSucceeded( + accountId: String, + requestId: String, + usage: ProviderUsage, + ) = Unit + + override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) = Unit + + override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) = Unit + + override suspend fun findSettlementPending(limit: Int): List = emptyList() +} + +private val REQUEST_ID_PRINCIPAL = GatewayPrincipal( + userId = "00000000-0000-0000-0000-000000000001", + grantId = "00000000-0000-0000-0000-000000000003", + scopes = setOf(GatewayCapability.AI), +) diff --git a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayGrantServiceTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayGrantServiceTest.kt new file mode 100644 index 0000000..04bda32 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayGrantServiceTest.kt @@ -0,0 +1,228 @@ +package com.osglab.account.features.gateway.services + +import com.osglab.account.features.gateway.GatewaySettings +import com.osglab.account.features.gateway.models.CreateGatewayGrantRequest +import com.osglab.account.features.gateway.models.GatewayCapability +import com.osglab.account.features.gateway.models.GatewayGrant +import com.osglab.account.features.gateway.models.GatewayPrincipal +import com.osglab.account.features.gateway.ports.GatewayGrantRepository +import com.osglab.account.features.gateway.ports.GatewayRefreshRotationResult +import com.osglab.account.features.gateway.ports.NewGatewayGrant +import com.osglab.account.features.gateway.ports.StoredGatewayRefresh +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset + +class GatewayGrantServiceTest : StringSpec({ + "issues a scope-limited token and stores only the refresh hash" { + val repository = FakeGrantRepository() + val service = grantService(repository) + + val tokens = service.create( + PRINCIPAL, + CreateGatewayGrantRequest(setOf(GatewayCapability.POLISH), 3_600), + "create-request-1", + ) + + repository.refreshes.values.single().tokenHash shouldBe repository.hash(tokens.refreshToken) + repository.refreshes.values.single().tokenHash.contains(tokens.refreshToken) shouldBe false + service.authenticate(tokens.accessToken)?.scopes shouldBe setOf(GatewayCapability.POLISH) + } + + "rejects scopes not held by the issuing identity" { + val repository = FakeGrantRepository() + val service = grantService(repository) + + shouldThrow { + service.create( + PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)), + CreateGatewayGrantRequest(setOf(GatewayCapability.AI)), + "create-request-scope", + ) + } + + repository.grants.size shouldBe 0 + } + + "refresh rotation is idempotent for the same operation key and detects reuse" { + val repository = FakeGrantRepository() + val service = grantService(repository) + val created = service.create( + PRINCIPAL, + CreateGatewayGrantRequest(setOf(GatewayCapability.AI)), + "create-request-2", + ) + + val rotated = service.refresh(created.refreshToken, "refresh-request-1") + val replay = service.refresh(created.refreshToken, "refresh-request-1") + + replay.refreshToken shouldBe rotated.refreshToken + shouldThrow { + service.refresh(created.refreshToken, "refresh-request-2") + } + service.authenticate(rotated.accessToken) shouldBe null + } + + "grant creation replay returns the same refresh credential without a duplicate grant" { + val repository = FakeGrantRepository() + val service = grantService(repository) + val request = CreateGatewayGrantRequest(setOf(GatewayCapability.ASR)) + + val first = service.create(PRINCIPAL, request, "create-request-4") + val replay = service.create(PRINCIPAL, request, "create-request-4") + + replay.grantId shouldBe first.grantId + replay.refreshToken shouldBe first.refreshToken + repository.grants.size shouldBe 1 + } + + "revocation immediately invalidates an otherwise unexpired access token" { + val repository = FakeGrantRepository() + val service = grantService(repository) + val tokens = service.create( + PRINCIPAL, + CreateGatewayGrantRequest(setOf(GatewayCapability.AGENT)), + "create-request-3", + ) + + service.revoke(PRINCIPAL, tokens.grantId) shouldBe true + + service.authenticate(tokens.accessToken) shouldBe null + } +}) + +private fun grantService(repository: GatewayGrantRepository) = GatewayGrantService( + repository = repository, + settings = GatewaySettings( + issuer = "osg-test", + audience = "osg-gateway-test", + accessTokenHmacSecret = ByteArray(32) { 1 }, + refreshTokenHmacSecret = ByteArray(32) { 2 }, + accessTokenLifetime = Duration.ofMinutes(5), + refreshTokenLifetime = Duration.ofDays(30), + maximumGrantLifetime = Duration.ofDays(90), + ), + clock = Clock.fixed(NOW, ZoneOffset.UTC), +) + +private class FakeGrantRepository : GatewayGrantRepository { + data class Refresh( + val tokenId: String, + val grantId: String, + val familyId: String, + val tokenHash: String, + val expiresAt: Instant, + var replacedById: String? = null, + var rotationKey: String? = null, + var revoked: Boolean = false, + ) + + val grants = mutableMapOf() + val refreshes = mutableMapOf() + private val createKeys = mutableMapOf, String>() + + override suspend fun create(grant: NewGatewayGrant, now: Instant): StoredGatewayRefresh { + val existingId = createKeys[grant.accountId to grant.idempotencyKey] + if (existingId != null) { + val existing = grants.getValue(existingId) + val refresh = refreshes.values.single { + it.grantId == existingId && it.replacedById == null && !it.revoked + } + return refresh.stored(existing) + } + val storedGrant = GatewayGrant(grant.id, grant.accountId, grant.scopes, grant.expiresAt) + grants[grant.id] = storedGrant + createKeys[grant.accountId to grant.idempotencyKey] = grant.id + refreshes[grant.refreshTokenHash] = Refresh( + tokenId = grant.refreshTokenId, + grantId = grant.id, + familyId = grant.refreshFamilyId, + tokenHash = grant.refreshTokenHash, + expiresAt = grant.refreshExpiresAt, + ) + return refreshes.getValue(grant.refreshTokenHash).stored(storedGrant) + } + + override suspend fun rotateRefresh( + currentTokenHash: String, + rotationIdempotencyKey: String, + newTokenId: String, + newTokenHash: String, + newExpiresAt: Instant, + now: Instant, + ): GatewayRefreshRotationResult { + val current = refreshes[currentTokenHash] ?: return GatewayRefreshRotationResult.Invalid + val grant = grants.getValue(current.grantId) + current.replacedById?.let { replacementId -> + if (current.rotationKey == rotationIdempotencyKey) { + val replacement = refreshes.values.single { it.tokenId == replacementId } + return GatewayRefreshRotationResult.Rotated(replacement.stored(grant)) + } + refreshes.values.filter { it.familyId == current.familyId }.forEach { it.revoked = true } + grants[current.grantId] = grant.copy(revokedAt = now) + return GatewayRefreshRotationResult.ReuseDetected + } + if (current.revoked || !current.expiresAt.isAfter(now) || grant.revokedAt != null) { + return GatewayRefreshRotationResult.Invalid + } + val replacement = Refresh( + tokenId = newTokenId, + grantId = current.grantId, + familyId = current.familyId, + tokenHash = newTokenHash, + expiresAt = minOf(newExpiresAt, grant.expiresAt), + ) + refreshes[newTokenHash] = replacement + current.replacedById = newTokenId + current.rotationKey = rotationIdempotencyKey + current.revoked = true + return GatewayRefreshRotationResult.Rotated(replacement.stored(grant)) + } + + override suspend fun revoke(accountId: String, grantId: String, now: Instant): Boolean { + val grant = grants[grantId]?.takeIf { it.accountId == accountId && it.revokedAt == null } + ?: return false + grants[grantId] = grant.copy(revokedAt = now) + refreshes.values.filter { it.grantId == grantId }.forEach { it.revoked = true } + return true + } + + override suspend fun findActive( + grantId: String, + accountId: String, + scopes: Set, + now: Instant, + ): GatewayGrant? = grants[grantId]?.takeIf { + it.accountId == accountId && + it.scopes == scopes && + it.revokedAt == null && + it.expiresAt.isAfter(now) + } + + override suspend fun isAllowed(accountId: String, capability: GatewayCapability): Boolean = + grants.values.any { + it.accountId == accountId && capability in it.scopes && it.revokedAt == null + } + + fun hash(token: String): String = + java.security.MessageDigest.getInstance("SHA-256") + .digest(token.encodeToByteArray()) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + + private fun Refresh.stored(grant: GatewayGrant) = StoredGatewayRefresh( + grant = grant, + tokenId = tokenId, + familyId = familyId, + expiresAt = expiresAt, + ) +} + +private val PRINCIPAL = GatewayPrincipal( + userId = "00000000-0000-0000-0000-000000000001", + scopes = GatewayCapability.entries.toSet(), +) +private val NOW = Instant.parse("2026-08-16T00:00:00Z") diff --git a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayReplayStateTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayReplayStateTest.kt new file mode 100644 index 0000000..c7eeaed --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayReplayStateTest.kt @@ -0,0 +1,231 @@ +package com.osglab.account.features.gateway.services + +import com.osglab.account.features.gateway.models.GatewayCapability +import com.osglab.account.features.gateway.models.GatewayPrincipal +import com.osglab.account.features.gateway.models.ProviderDescriptor +import com.osglab.account.features.gateway.models.ProviderOutput +import com.osglab.account.features.gateway.models.ProviderRequest +import com.osglab.account.features.gateway.models.ProviderUsage +import com.osglab.account.features.gateway.models.TextProviderRequest +import com.osglab.account.features.gateway.models.UsageMeter +import com.osglab.account.features.gateway.ports.CreditMeterPort +import com.osglab.account.features.gateway.ports.CreditReservation +import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException +import com.osglab.account.features.gateway.ports.GatewayUsagePort +import com.osglab.account.features.gateway.ports.PendingSettlement +import com.osglab.account.features.gateway.ports.ProviderRequestMetadata +import com.osglab.account.features.gateway.ports.ProviderRequestState +import com.osglab.account.features.gateway.providers.GatewayProvider +import com.osglab.account.features.gateway.providers.ProviderCatalog +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.delay + +class GatewayReplayStateTest : StringSpec({ + "an account-scoped replay never calls upstream twice" { + val credits = ReplayCredits() + val records = ReplayRecords() + val provider = ReplayProvider() + val service = replayService(credits, records, provider) + + service.execute(PRINCIPAL_A, replayRequest(), DISCARD) + shouldThrow { + service.execute(PRINCIPAL_A, replayRequest(), DISCARD) + } + + provider.calls shouldBe 1 + } + + "the same request ID is independent across accounts" { + val records = ReplayRecords() + val provider = ReplayProvider() + val service = replayService(ReplayCredits(), records, provider) + + service.execute(PRINCIPAL_A, replayRequest(), DISCARD) + service.execute(PRINCIPAL_B, replayRequest(), DISCARD) + + provider.calls shouldBe 2 + records.state(PRINCIPAL_A.userId) shouldBe ProviderRequestState.SETTLED + records.state(PRINCIPAL_B.userId) shouldBe ProviderRequestState.SETTLED + } + + "whole-call timeout releases only the incomplete provider call" { + val credits = ReplayCredits() + val records = ReplayRecords() + val service = replayService( + credits, + records, + ReplayProvider(delayMillis = 100), + timeoutMillis = 10, + ) + + shouldThrow { + service.execute(PRINCIPAL_A, replayRequest(), DISCARD) + } + + credits.releases shouldBe 1 + records.state(PRINCIPAL_A.userId) shouldBe ProviderRequestState.RELEASED + } + + "completed inconsistent provider usage releases the reservation" { + val credits = ReplayCredits() + val records = ReplayRecords() + val service = replayService( + credits, + records, + ReplayProvider( + usage = VALID_USAGE.copy(units = VALID_USAGE.units + 1), + ), + ) + + shouldThrow { + service.execute(PRINCIPAL_A, replayRequest(), DISCARD) + } + + credits.releases shouldBe 1 + records.state(PRINCIPAL_A.userId) shouldBe ProviderRequestState.RELEASED + } + + "marks a claimed request for review when release also fails" { + val credits = ReplayCredits(failRelease = true) + val records = ReplayRecords(failStart = true) + val service = replayService(credits, records, ReplayProvider()) + + shouldThrow { + service.execute(PRINCIPAL_A, replayRequest(), DISCARD) + } + + records.state(PRINCIPAL_A.userId) shouldBe ProviderRequestState.MANUAL_REVIEW + } +}) + +private fun replayService( + credits: CreditMeterPort, + records: GatewayUsagePort, + provider: GatewayProvider, + timeoutMillis: Long = 1_000, +) = GatewayService( + catalog = ProviderCatalog(listOf(provider)), + credits = credits, + grants = { _, _ -> true }, + usageRecords = records, + llmProviderTimeoutMillis = timeoutMillis, + asrProviderTimeoutMillis = timeoutMillis, +) + +private fun replayRequest() = TextProviderRequest( + requestId = REPLAY_ID, + capability = GatewayCapability.AI, + input = "hello", + context = null, + maxOutputTokens = 32, + temperature = 0.2, + stream = false, +) + +private class ReplayCredits( + private val failRelease: Boolean = false, +) : CreditMeterPort { + private val reservations = mutableMapOf, CreditReservation>() + var releases = 0 + + override suspend fun reserve( + accountId: String, + meter: UsageMeter, + estimatedUnits: Long, + requestId: String, + ): CreditReservation = reservations.getOrPut(accountId to requestId) { + CreditReservation("reservation-$accountId-$requestId", estimatedUnits) + } + + override suspend fun settle(reservationId: String, actualUnits: Long) = Unit + override suspend fun release(reservationId: String) { + releases += 1 + if (failRelease) throw ReleaseFailure() + } +} + +private class ReplayProvider( + private val delayMillis: Long = 0, + private val usage: ProviderUsage = VALID_USAGE, +) : GatewayProvider { + var calls = 0 + override val descriptor = ProviderDescriptor( + id = "replay-provider", + capabilities = setOf(GatewayCapability.AI), + streaming = true, + usageMeter = UsageMeter.LLM_TOKEN, + ) + + override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage { + calls += 1 + if (delayMillis > 0) delay(delayMillis) + return usage + } +} + +private class ReplayRecords( + private val failStart: Boolean = false, +) : GatewayUsagePort { + private data class Record( + val metadata: ProviderRequestMetadata, + var state: ProviderRequestState, + var usage: ProviderUsage? = null, + ) + + private val values = mutableMapOf, Record>() + + override suspend fun claim(metadata: ProviderRequestMetadata) { + val key = metadata.accountId to metadata.requestId + values[key]?.let { throw GatewayRequestAlreadyClaimedException(it.state) } + values[key] = Record(metadata, ProviderRequestState.CLAIMED) + } + + override suspend fun markStarted(accountId: String, requestId: String) { + if (failStart) throw StartRecordingFailure() + values.getValue(accountId to requestId).state = ProviderRequestState.STARTED + } + + override suspend fun markSettlementPending( + accountId: String, + requestId: String, + usage: ProviderUsage, + ) { + values.getValue(accountId to requestId).apply { + state = ProviderRequestState.SETTLEMENT_PENDING + this.usage = usage + } + } + + override suspend fun markSucceeded(accountId: String, requestId: String, usage: ProviderUsage) { + values.getValue(accountId to requestId).state = ProviderRequestState.SETTLED + } + + override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) { + values.getValue(accountId to requestId).state = ProviderRequestState.RELEASED + } + + override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) { + values.getValue(accountId to requestId).state = ProviderRequestState.MANUAL_REVIEW + } + + override suspend fun findSettlementPending(limit: Int): List = emptyList() + + fun state(accountId: String): ProviderRequestState = + values.getValue(accountId to REPLAY_ID).state +} + +private val PRINCIPAL_A = GatewayPrincipal("account-a", scopes = setOf(GatewayCapability.AI)) +private val PRINCIPAL_B = GatewayPrincipal("account-b", scopes = setOf(GatewayCapability.AI)) +private val DISCARD = ProviderOutput { } +private val VALID_USAGE = ProviderUsage( + meter = UsageMeter.LLM_TOKEN, + units = 7, + inputUnits = 5, + outputUnits = 2, +) +private const val REPLAY_ID = "request-replay" + +private class StartRecordingFailure : RuntimeException() +private class ReleaseFailure : RuntimeException() diff --git a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt new file mode 100644 index 0000000..95650cc --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt @@ -0,0 +1,289 @@ +package com.osglab.account.features.gateway.services + +import com.osglab.account.features.gateway.models.GatewayCapability +import com.osglab.account.features.gateway.models.GatewayPrincipal +import com.osglab.account.features.gateway.models.ProviderDescriptor +import com.osglab.account.features.gateway.models.ProviderOutput +import com.osglab.account.features.gateway.models.ProviderRequest +import com.osglab.account.features.gateway.models.ProviderUsage +import com.osglab.account.features.gateway.models.TextProviderRequest +import com.osglab.account.features.gateway.models.UsageMeter +import com.osglab.account.features.gateway.ports.CreditMeterPort +import com.osglab.account.features.gateway.ports.CreditReservation +import com.osglab.account.features.gateway.ports.GatewayUsagePort +import com.osglab.account.features.gateway.ports.PendingSettlement +import com.osglab.account.features.gateway.ports.ProviderRequestMetadata +import com.osglab.account.features.gateway.ports.ProviderUsageEstimate +import com.osglab.account.features.gateway.providers.GatewayProvider +import com.osglab.account.features.gateway.providers.ProviderCatalog +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +class GatewayServiceBillingTest : StringSpec({ + "releases a reservation when the mock upstream fails" { + val credits = FakeCredits() + val service = service(credits, FakeProvider(fail = true)) + + shouldThrow { + service.execute(PRINCIPAL, request(), DISCARD_OUTPUT) + } + + credits.settled shouldBe emptyList() + credits.released.shouldContainExactly(RESERVATION_ID) + } + + "releases a reservation when the provider reports an empty result" { + val credits = FakeCredits() + val service = service(credits, EmptyResultProvider()) + + shouldThrow { + service.execute(PRINCIPAL, request(), DISCARD_OUTPUT) + } + + credits.released.shouldContainExactly(RESERVATION_ID) + } + + "settles successful token usage and does not release" { + val credits = FakeCredits() + val service = service(credits, FakeProvider()) + + service.execute(PRINCIPAL, request(), DISCARD_OUTPUT) + + credits.settled.shouldContainExactly(RESERVATION_ID to 21L) + credits.released shouldBe emptyList() + credits.lastEstimate?.meter shouldBe UsageMeter.LLM_TOKEN + credits.lastEstimate?.inputUnits shouldBe 261L + credits.lastEstimate?.outputUnits shouldBe 32L + } + + "releases a reservation when a provider call is cancelled" { + val credits = FakeCredits() + val started = CompletableDeferred() + val provider = object : GatewayProvider { + override val descriptor = ProviderDescriptor( + id = "cancellable-provider", + capabilities = setOf(GatewayCapability.AI), + streaming = true, + usageMeter = UsageMeter.LLM_TOKEN, + ) + + override suspend fun execute( + request: ProviderRequest, + output: ProviderOutput, + ): ProviderUsage { + started.complete(Unit) + awaitCancellation() + } + } + val service = service(credits, provider) + + coroutineScope { + val call = launch { service.execute(PRINCIPAL, request(), DISCARD_OUTPUT) } + started.await() + call.cancelAndJoin() + } + + credits.released.shouldContainExactly(RESERVATION_ID) + credits.settled shouldBe emptyList() + } + + "keeps the reservation frozen when settlement fails after upstream success" { + val credits = FakeCredits(failSettle = true) + val service = service(credits, FakeProvider()) + + service.execute(PRINCIPAL, request(), DISCARD_OUTPUT) + + credits.settled.shouldContainExactly(RESERVATION_ID to 21L) + credits.released shouldBe emptyList() + } + + "repeated settlement delegates idempotency to the credit port" { + val credits = FakeCredits(idempotent = true) + val usageRecords = FakeUsageRecords( + pending = mutableListOf( + PendingSettlement( + requestId = "request-123", + accountId = PRINCIPAL.userId, + reservationId = RESERVATION_ID, + usage = TOKEN_USAGE, + ), + ), + ) + val reconciliation = GatewayReconciliationService(credits, usageRecords) + + reconciliation.reconcile() + reconciliation.reconcile() + + credits.settled.shouldContainExactly(RESERVATION_ID to 21L) + } + + "delegates an explicit settled-call reversal to billing refund" { + val credits = FakeCredits() + + GatewayRefundService(credits).refund(RESERVATION_ID) + + credits.refunded.shouldContainExactly(RESERVATION_ID) + } + + "rejects a capability outside the gateway token scope before billing" { + val credits = FakeCredits() + val service = service(credits, FakeProvider()) + + shouldThrow { + service.execute( + PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)), + request(), + DISCARD_OUTPUT, + ) + } + + credits.reserveCalls shouldBe 0 + } +}) + +private fun service( + credits: CreditMeterPort, + provider: GatewayProvider, + usageRecords: GatewayUsagePort = FakeUsageRecords(), +): GatewayService = GatewayService( + catalog = ProviderCatalog(listOf(provider)), + credits = credits, + grants = { _, _ -> true }, + usageRecords = usageRecords, +) + +private fun request() = TextProviderRequest( + requestId = "request-123", + capability = GatewayCapability.AI, + input = "hello", + context = null, + maxOutputTokens = 32, + temperature = 0.2, + stream = false, +) + +private class FakeCredits( + private val failSettle: Boolean = false, + private val idempotent: Boolean = false, +) : CreditMeterPort { + val settled = mutableListOf>() + val released = mutableListOf() + val refunded = mutableListOf() + var reserveCalls = 0 + var lastEstimate: ProviderUsageEstimate? = null + + override suspend fun reserve( + accountId: String, + meter: UsageMeter, + estimatedUnits: Long, + requestId: String, + ): CreditReservation { + reserveCalls += 1 + return CreditReservation(RESERVATION_ID, estimatedUnits) + } + + override suspend fun reserve( + accountId: String, + estimate: ProviderUsageEstimate, + requestId: String, + ): CreditReservation { + lastEstimate = estimate + return reserve(accountId, estimate.meter, estimate.units, requestId) + } + + override suspend fun settle(reservationId: String, actualUnits: Long) { + val settlement = reservationId to actualUnits + if (!idempotent || settlement !in settled) settled += settlement + if (failSettle) throw BillingFailure() + } + + override suspend fun settle(reservationId: String, usage: ProviderUsage) { + settle(reservationId, usage.units) + } + + override suspend fun release(reservationId: String) { + if (!idempotent || reservationId !in released) released += reservationId + } + + override suspend fun refund(reservationId: String) { + if (!idempotent || reservationId !in refunded) refunded += reservationId + } +} + +private class FakeProvider( + private val fail: Boolean = false, +) : GatewayProvider { + override val descriptor = ProviderDescriptor( + id = "mock-deepseek", + capabilities = setOf(GatewayCapability.AI), + streaming = true, + usageMeter = UsageMeter.LLM_TOKEN, + ) + + override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage { + if (fail) throw ProviderFailure() + return TOKEN_USAGE + } +} + +private class EmptyResultProvider : GatewayProvider { + override val descriptor = ProviderDescriptor( + id = "empty-provider", + capabilities = setOf(GatewayCapability.AI), + streaming = true, + usageMeter = UsageMeter.LLM_TOKEN, + ) + + override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage { + throw EmptyResultFailure() + } +} + +private class FakeUsageRecords( + private val pending: MutableList = mutableListOf(), +) : GatewayUsagePort { + override suspend fun claim(metadata: ProviderRequestMetadata) = Unit + override suspend fun markStarted(accountId: String, requestId: String) = Unit + override suspend fun markSettlementPending( + accountId: String, + requestId: String, + usage: ProviderUsage, + ) = Unit + + override suspend fun markSucceeded( + accountId: String, + requestId: String, + usage: ProviderUsage, + ) { + pending.removeAll { it.accountId == accountId && it.requestId == requestId } + } + + override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) = Unit + override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) = Unit + override suspend fun findSettlementPending(limit: Int): List = + pending.take(limit) +} + +private val TOKEN_USAGE = ProviderUsage( + meter = UsageMeter.LLM_TOKEN, + units = 21, + inputUnits = 13, + outputUnits = 8, +) +private val PRINCIPAL = GatewayPrincipal( + userId = "account-1", + scopes = setOf(GatewayCapability.AI), +) +private val DISCARD_OUTPUT = ProviderOutput { } +private const val RESERVATION_ID = "reservation-1" + +private class BillingFailure : RuntimeException() +private class ProviderFailure : RuntimeException() +private class EmptyResultFailure : RuntimeException() diff --git a/src/test/kotlin/com/osglab/account/features/integrity/AppAttestCryptoTest.kt b/src/test/kotlin/com/osglab/account/features/integrity/AppAttestCryptoTest.kt new file mode 100644 index 0000000..dab6d2e --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/integrity/AppAttestCryptoTest.kt @@ -0,0 +1,196 @@ +package com.osglab.account.features.integrity + +import com.osglab.account.config.AppleServiceEnvironment +import com.osglab.account.config.IntegrityConfig +import com.osglab.account.config.IntegrityPolicy +import com.upokecenter.cbor.CBORObject +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.math.BigInteger +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.Signature +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.util.Base64 + +class AppAttestCryptoTest : FunSpec({ + test("attestation validates nonce RP ID AAGUID credential and counter") { + val fixture = AppAttestFixture() + val crypto = fixture.crypto() + + val material = crypto.validateAttestation( + fixture.attestationObject(), + fixture.keyId, + fixture.challenge, + ) + + material.publicKey shouldBe fixture.keyPair.public.encoded + material.initialCounter shouldBe 0L + } + + test("attestation rejects a nonce mismatch") { + val fixture = AppAttestFixture() + val crypto = fixture.crypto(nonce = ByteArray(32) { 9 }) + + shouldThrow { + crypto.validateAttestation( + fixture.attestationObject(), + fixture.keyId, + fixture.challenge, + ) + } + } + + test("attestation rejects an RP ID mismatch") { + val fixture = AppAttestFixture() + + shouldThrow { + fixture.crypto().validateAttestation( + fixture.attestationObject(rpIdHash = ByteArray(32)), + fixture.keyId, + fixture.challenge, + ) + } + } + + test("attestation rejects an AAGUID environment mismatch") { + val fixture = AppAttestFixture() + + shouldThrow { + fixture.crypto().validateAttestation( + fixture.attestationObject( + aaguid = "appattestdevelop".toByteArray(Charsets.US_ASCII), + ), + fixture.keyId, + fixture.challenge, + ) + } + } + + test("assertion verifies ECDSA and requires a strictly increasing counter") { + val fixture = AppAttestFixture() + val hash = sha256ForTest("cost-request".toByteArray()) + val assertion = fixture.assertionObject(counter = 4, clientDataHash = hash) + + fixture.crypto().validateAssertion( + assertionObject = assertion, + clientDataHash = hash, + publicKey = fixture.keyPair.public.encoded, + lastCounter = 3, + ) shouldBe 4L + + shouldThrow { + fixture.crypto().validateAssertion( + assertionObject = assertion, + clientDataHash = hash, + publicKey = fixture.keyPair.public.encoded, + lastCounter = 4, + ) + } + } +}) + +private class AppAttestFixture { + val keyPair: KeyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + val challenge: ByteArray = ByteArray(32) { it.toByte() } + val rpIdHash: ByteArray = sha256ForTest("X329MZU23S.com.osgkeyboard.ios".toByteArray()) + val keyId: String = Base64.getEncoder().encodeToString( + sha256ForTest(uncompressedPointForTest(keyPair.public as ECPublicKey)), + ) + + fun crypto(nonce: ByteArray = expectedNonce()): LibraryAppAttestCrypto = + LibraryAppAttestCrypto( + IntegrityConfig( + deviceCheckPolicy = IntegrityPolicy.ENFORCE, + appAttestPolicy = IntegrityPolicy.ENFORCE, + appleEnvironment = AppleServiceEnvironment.PRODUCTION, + ), + AppAttestCertificateValidator { + ValidatedAppAttestCertificate(keyPair.public as ECPublicKey, nonce) + }, + ) + + fun attestationObject( + rpIdHash: ByteArray = this.rpIdHash, + aaguid: ByteArray = "appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7), + ): ByteArray { + val authData = attestationAuthData(rpIdHash, aaguid) + return CBORObject.NewMap() + .Add("fmt", "apple-appattest") + .Add( + "attStmt", + CBORObject.NewMap() + .Add("x5c", CBORObject.NewArray().Add(byteArrayOf(1))) + .Add("receipt", byteArrayOf(2)), + ) + .Add("authData", authData) + .EncodeToBytes() + } + + fun assertionObject(counter: Int, clientDataHash: ByteArray): ByteArray { + val authData = ByteBuffer.allocate(37).order(ByteOrder.BIG_ENDIAN) + .put(rpIdHash) + .put(0) + .putInt(counter) + .array() + val signature = Signature.getInstance("SHA256withECDSA").run { + initSign(keyPair.private) + update(authData + clientDataHash) + sign() + } + return CBORObject.NewMap() + .Add("authenticatorData", authData) + .Add("signature", signature) + .EncodeToBytes() + } + + private fun expectedNonce(): ByteArray = + sha256ForTest(attestationAuthData(rpIdHash, productionAaguid()) + sha256ForTest(challenge)) + + private fun productionAaguid(): ByteArray = + "appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7) + + private fun attestationAuthData(rpHash: ByteArray, aaguid: ByteArray): ByteArray { + val publicKey = keyPair.public as ECPublicKey + val credentialId = Base64.getDecoder().decode(keyId) + val cose = CBORObject.NewMap() + .Add(1, 2) + .Add(3, -7) + .Add(-1, 1) + .Add(-2, publicKey.w.affineX.toFixedForTest(32)) + .Add(-3, publicKey.w.affineY.toFixedForTest(32)) + .EncodeToBytes() + return ByteBuffer.allocate(32 + 1 + 4 + 16 + 2 + credentialId.size + cose.size) + .order(ByteOrder.BIG_ENDIAN) + .put(rpHash) + .put(0x40) + .putInt(0) + .put(aaguid) + .putShort(credentialId.size.toShort()) + .put(credentialId) + .put(cose) + .array() + } +} + +private fun uncompressedPointForTest(key: ECPublicKey): ByteArray = + byteArrayOf(0x04) + + key.w.affineX.toFixedForTest(32) + + key.w.affineY.toFixedForTest(32) + +private fun BigInteger.toFixedForTest(size: Int): ByteArray { + val bytes = toByteArray().let { + if (it.size == size + 1 && it.first() == 0.toByte()) it.copyOfRange(1, it.size) else it + } + return ByteArray(size - bytes.size) + bytes +} + +private fun sha256ForTest(value: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(value) diff --git a/src/test/kotlin/com/osglab/account/features/integrity/AppAttestServiceTest.kt b/src/test/kotlin/com/osglab/account/features/integrity/AppAttestServiceTest.kt new file mode 100644 index 0000000..2d0ac5e --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/integrity/AppAttestServiceTest.kt @@ -0,0 +1,229 @@ +package com.osglab.account.features.integrity + +import com.osglab.account.config.IntegrityConfig +import com.osglab.account.config.IntegrityPolicy +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeTypeOf +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.Base64 +import java.util.UUID + +class AppAttestServiceTest : FunSpec({ + val keyId = Base64.getEncoder().encodeToString(ByteArray(32) { 7 }) + val payload = AppleSignInIntegrityPayload("identity", "authorization", "nonce") + val assertion = Base64.getEncoder().encodeToString(byteArrayOf(1)) + + test("expired challenge is rejected before assertion validation") { + val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z")) + val repository = InMemoryAppAttestRepository(keyId, counter = 0) + val crypto = FakeAppAttestCrypto(nextCounter = 1) + val service = appAttestService(repository, crypto, clock) + val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId) + clock.now = challenge.expiresAt.plusSeconds(1) + + service.verify( + AppAttestEvidence( + keyId, + challenge.id.toString(), + assertion, + Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.value), + ), + payload, + ).shouldBeTypeOf() + crypto.assertionCalls shouldBe 0 + } + + test("challenge cannot be replayed") { + val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z")) + val repository = InMemoryAppAttestRepository(keyId, counter = 0) + val crypto = FakeAppAttestCrypto(nextCounter = 1) + val service = appAttestService(repository, crypto, clock) + val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId) + val evidence = AppAttestEvidence( + keyId, + challenge.id.toString(), + assertion, + Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.value), + ) + + service.verify(evidence, payload) shouldBe IntegrityVerification.Verified + service.verify(evidence, payload).shouldBeTypeOf() + crypto.assertionCalls shouldBe 1 + } + + test("counter rollback is rejected even if a crypto adapter returns it") { + val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z")) + val repository = InMemoryAppAttestRepository(keyId, counter = 8) + val crypto = FakeAppAttestCrypto(nextCounter = 8) + val service = appAttestService(repository, crypto, clock) + val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId) + + service.verify( + AppAttestEvidence( + keyId, + challenge.id.toString(), + assertion, + Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.value), + ), + payload, + ).shouldBeTypeOf() + repository.keys.getValue(keyId).counter shouldBe 8 + } + + test("server rebuilds canonical client data from the challenge and login fields") { + val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z")) + val repository = InMemoryAppAttestRepository(keyId, counter = 2) + val crypto = FakeAppAttestCrypto(nextCounter = 3) + val service = appAttestService(repository, crypto, clock) + val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId) + + service.verify( + AppAttestEvidence( + keyId, + challenge.id.toString(), + assertion, + Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.value), + ), + payload, + ) shouldBe IntegrityVerification.Verified + crypto.clientDataHash shouldBe java.security.MessageDigest.getInstance("SHA-256") + .digest(AppAttestCanonicalPayload.appleSignIn(challenge.value, payload)) + } + + test("bound assertion rejects a key owned by another account") { + val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z")) + val ownerId = UUID.randomUUID() + val requesterId = UUID.randomUUID() + val repository = InMemoryAppAttestRepository(keyId, counter = 0, accountId = ownerId) + val crypto = FakeAppAttestCrypto(nextCounter = 1) + val service = appAttestService(repository, crypto, clock) + val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId) + + shouldThrow { + service.verifyBoundAssertion( + challengeId = challenge.id.toString(), + challenge = challenge.value, + keyId = keyId, + assertionObject = assertion, + expectedClientDataHash = ByteArray(32), + expectedAccountId = requesterId, + ) + } + crypto.assertionCalls shouldBe 0 + } +}) + +private fun appAttestService( + repository: AppAttestRepository, + crypto: AppAttestCrypto, + clock: Clock, +) = AppAttestService( + repository = repository, + crypto = crypto, + config = IntegrityConfig( + deviceCheckPolicy = IntegrityPolicy.MONITOR, + appAttestPolicy = IntegrityPolicy.ENFORCE, + challengeLifetimeSeconds = 300, + ), + clock = clock, +) + +private class FakeAppAttestCrypto( + private val nextCounter: Long, +) : AppAttestCrypto { + var assertionCalls = 0 + var clientDataHash: ByteArray? = null + + override suspend fun validateAttestation( + attestationObject: ByteArray, + keyId: String, + challenge: ByteArray, + ) = AttestedKeyMaterial(byteArrayOf(1), byteArrayOf(2), 0) + + override suspend fun validateAssertion( + assertionObject: ByteArray, + clientDataHash: ByteArray, + publicKey: ByteArray, + lastCounter: Long, + ): Long { + assertionCalls++ + this.clientDataHash = clientDataHash + return nextCounter + } +} + +private class InMemoryAppAttestRepository( + keyId: String, + counter: Long, + accountId: UUID? = null, +) : AppAttestRepository { + private val challenges = mutableMapOf() + val keys = mutableMapOf( + keyId to StoredAppAttestKey(keyId, byteArrayOf(1), byteArrayOf(2), counter, accountId), + ) + + override suspend fun createChallenge(challenge: AppAttestChallenge) { + challenges[challenge.id] = challenge + } + + override suspend fun consumeChallenge( + id: UUID, + purpose: AppAttestChallengePurpose, + keyId: String, + challengeHash: String, + accountId: UUID?, + now: Instant, + ): ConsumedChallenge { + val challenge = challenges[id] ?: return ConsumedChallenge.MissingOrMismatched + if (challenge.purpose != purpose || challenge.keyId != keyId) { + return ConsumedChallenge.MissingOrMismatched + } + if (challenge.challengeHash != challengeHash) { + return ConsumedChallenge.MissingOrMismatched + } + if (challenge.status == AppAttestChallengeStatus.CONSUMED) return ConsumedChallenge.Replayed + if (!challenge.expiresAt.isAfter(now)) return ConsumedChallenge.Expired + challenges[id] = challenge.copy( + status = AppAttestChallengeStatus.CONSUMED, + consumedAt = now, + ) + return ConsumedChallenge.Valid + } + + override suspend fun saveKey(key: StoredAppAttestKey): Boolean = + keys.putIfAbsent(key.keyId, key) == null + + override suspend fun findKey(keyId: String): StoredAppAttestKey? = keys[keyId] + + override suspend fun updateCounter( + keyId: String, + expectedCounter: Long, + newCounter: Long, + now: Instant, + ): Boolean { + val key = keys[keyId] ?: return false + if (key.counter != expectedCounter || newCounter <= expectedCounter) return false + keys[keyId] = key.copy(counter = newCounter) + return true + } + + override suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant): Boolean { + val key = keys[keyId] ?: return false + if (key.accountId != null && key.accountId != accountId) return false + keys[keyId] = key.copy(accountId = accountId) + return true + } +} + +private class MutableClock( + var now: Instant, +) : Clock() { + override fun instant(): Instant = now + override fun getZone(): ZoneId = ZoneOffset.UTC + override fun withZone(zone: ZoneId): Clock = this +} diff --git a/src/test/kotlin/com/osglab/account/features/integrity/BundledAppleAppAttestTrustTest.kt b/src/test/kotlin/com/osglab/account/features/integrity/BundledAppleAppAttestTrustTest.kt new file mode 100644 index 0000000..3b38552 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/integrity/BundledAppleAppAttestTrustTest.kt @@ -0,0 +1,14 @@ +package com.osglab.account.features.integrity + +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class BundledAppleAppAttestTrustTest { + @Test + fun `bundled root is the official Apple App Attestation CA`() { + val certificate = BundledAppleAppAttestTrust.loadRootCertificate() + + certificate.subjectX500Principal.name.contains("Apple App Attestation Root CA") shouldBe true + (certificate.basicConstraints >= 0) shouldBe true + } +} diff --git a/src/test/kotlin/com/osglab/account/features/integrity/DeviceCheckTest.kt b/src/test/kotlin/com/osglab/account/features/integrity/DeviceCheckTest.kt new file mode 100644 index 0000000..7d08f42 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/integrity/DeviceCheckTest.kt @@ -0,0 +1,350 @@ +package com.osglab.account.features.integrity + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.crypto.ECDSAVerifier +import com.nimbusds.jwt.SignedJWT +import com.osglab.account.common.errors.ExternalServiceUnavailableException +import com.osglab.account.config.AppleServiceEnvironment +import com.osglab.account.config.IntegrityPolicy +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.HttpTimeout +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.security.KeyPairGenerator +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.Base64 +import java.util.UUID + +class DeviceCheckTest : FunSpec({ + test("ES256 JWT contains Apple team and key identifiers") { + val keyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + val pem = """ + -----BEGIN PRIVATE KEY----- + ${Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(keyPair.private.encoded)} + -----END PRIVATE KEY----- + """.trimIndent() + val now = Instant.parse("2026-08-16T00:00:00Z") + + val encoded = DeviceCheckJwtGenerator( + teamId = "X329MZU23S", + keyId = "APPLEKEY1", + privateKeyPem = pem, + clock = Clock.fixed(now, ZoneOffset.UTC), + ).create() + + val jwt = SignedJWT.parse(encoded) + jwt.header.algorithm shouldBe JWSAlgorithm.ES256 + jwt.header.keyID shouldBe "APPLEKEY1" + jwt.jwtClaimsSet.issuer shouldBe "X329MZU23S" + jwt.jwtClaimsSet.issueTime.toInstant() shouldBe now + jwt.jwtClaimsSet.expirationTime.toInstant() shouldBe now.plusSeconds(55 * 60) + jwt.verify(ECDSAVerifier(keyPair.public as ECPublicKey)).shouldBeTrue() + } + + test("an Apple bit0 claim never grants signup credits") { + val repository = InMemoryTrialRepository() + var updates = 0 + var grants = 0 + val service = DeviceCheckTrialService( + repository = repository, + client = object : AppleDeviceCheckClient { + override suspend fun query(deviceToken: String) = + DeviceCheckQuery.Found(DeviceCheckState(true, false, "2026-08")) + + override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) { + updates++ + } + }, + creditGranter = TrialCreditGranter { grants++ }, + policy = IntegrityPolicy.ENFORCE, + ) + + service.claimAndGrant(UUID.randomUUID(), Base64.getEncoder().encodeToString(ByteArray(32))).shouldBeFalse() + updates shouldBe 0 + grants shouldBe 0 + repository.claims.values.single().status shouldBe TrialClaimStatus.REJECTED + } + + test("Apple is marked before the idempotent credit boundary") { + val events = mutableListOf() + val repository = InMemoryTrialRepository(events) + var queryCount = 0 + val service = DeviceCheckTrialService( + repository = repository, + client = object : AppleDeviceCheckClient { + override suspend fun query(deviceToken: String): DeviceCheckQuery = + if (queryCount++ == 0) { + DeviceCheckQuery.NotFound + } else { + DeviceCheckQuery.Found(DeviceCheckState(true, false, "2026-08")) + } + + override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) { + bit0.shouldBeTrue() + events += "apple" + } + }, + creditGranter = TrialCreditGranter { events += "credits" }, + policy = IntegrityPolicy.ENFORCE, + ) + + service.claimAndGrant(UUID.randomUUID(), Base64.getEncoder().encodeToString(ByteArray(32) { 1 })) + .shouldBeTrue() + (events.indexOf("apple") < events.indexOf("credits")) shouldBe true + events shouldBe listOf("apple", "APPLE_MARKED", "credits", "COMPLETED") + } + + test("monitor skips a trial while enforce fails closed on Apple outage") { + val unavailable = object : AppleDeviceCheckClient { + override suspend fun query(deviceToken: String): DeviceCheckQuery = + throw DeviceCheckUnavailableException("network") + + override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean): Unit = + error("not reached") + } + val token = Base64.getEncoder().encodeToString(ByteArray(32) { 2 }) + val accountId = UUID.randomUUID() + + DeviceCheckTrialService( + InMemoryTrialRepository(), + unavailable, + TrialCreditGranter { error("must not grant") }, + IntegrityPolicy.MONITOR, + ).claimAndGrant(accountId, token).shouldBeFalse() + + shouldThrow { + DeviceCheckTrialService( + InMemoryTrialRepository(), + unavailable, + TrialCreditGranter { error("must not grant") }, + IntegrityPolicy.ENFORCE, + ).claimAndGrant(accountId, token) + } + } + + test("different ephemeral tokens for one device are serialized across the claim window") { + val repository = InMemoryTrialRepository() + val lock = Mutex() + var appleBit = false + var grants = 0 + val service = DeviceCheckTrialService( + repository = repository, + client = object : AppleDeviceCheckClient { + override suspend fun query(deviceToken: String): DeviceCheckQuery = + DeviceCheckQuery.Found(DeviceCheckState(appleBit, false, null)) + + override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) { + appleBit = bit0 + } + }, + creditGranter = TrialCreditGranter { grants++ }, + policy = IntegrityPolicy.ENFORCE, + mutex = object : DeviceCheckTrialMutex { + override suspend fun withLock(block: suspend () -> T): T = + lock.withLock { block() } + }, + ) + + val results = coroutineScope { + listOf(3, 4).map { marker -> + async { + service.claimAndGrant( + UUID.randomUUID(), + Base64.getEncoder().encodeToString(ByteArray(32) { marker.toByte() }), + ) + } + }.awaitAll() + } + + results.count { it } shouldBe 1 + grants shouldBe 1 + } + + test("an unconfirmed Apple mark never grants credits") { + var grants = 0 + val service = DeviceCheckTrialService( + repository = InMemoryTrialRepository(), + client = object : AppleDeviceCheckClient { + override suspend fun query(deviceToken: String) = DeviceCheckQuery.NotFound + + override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) = Unit + }, + creditGranter = TrialCreditGranter { grants++ }, + policy = IntegrityPolicy.ENFORCE, + ) + + shouldThrow { + service.claimAndGrant( + UUID.randomUUID(), + Base64.getEncoder().encodeToString(ByteArray(32) { 5 }), + ) + } + grants shouldBe 0 + } + + test("DeviceCheck maps rejected and temporary Apple errors differently") { + suspend fun queryFor(status: HttpStatusCode): Throwable { + val client = HttpClient( + MockEngine { + respond( + content = """{"error":"redacted"}""", + status = status, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(HttpTimeout) + } + return try { + runCatching { + KtorAppleDeviceCheckClient( + client, + testJwtGenerator(), + AppleServiceEnvironment.PRODUCTION, + ).query(Base64.getEncoder().encodeToString(ByteArray(32))) + }.exceptionOrNull()!! + } finally { + client.close() + } + } + + queryFor(HttpStatusCode.BadRequest)::class shouldBe DeviceCheckRejectedException::class + queryFor(HttpStatusCode.Unauthorized)::class shouldBe DeviceCheckUnavailableException::class + queryFor(HttpStatusCode.TooManyRequests)::class shouldBe DeviceCheckUnavailableException::class + queryFor(HttpStatusCode.ServiceUnavailable)::class shouldBe DeviceCheckUnavailableException::class + } + + test("DeviceCheck uses environment-specific Apple hosts") { + suspend fun hostFor(environment: AppleServiceEnvironment): String { + var host = "" + val client = HttpClient( + MockEngine { request -> + host = request.url.host + respond( + content = """{"bit0":false,"bit1":false}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(HttpTimeout) + } + try { + KtorAppleDeviceCheckClient(client, testJwtGenerator(), environment) + .query(Base64.getEncoder().encodeToString(ByteArray(32))) + } finally { + client.close() + } + return host + } + + hostFor(AppleServiceEnvironment.DEVELOPMENT) shouldBe + "api.development.devicecheck.apple.com" + hostFor(AppleServiceEnvironment.PRODUCTION) shouldBe + "api.devicecheck.apple.com" + } + + test("DeviceCheck maps Apple's successful missing-state response to NotFound") { + val client = HttpClient( + MockEngine { + respond( + content = "Failed to find bit state\n", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + }, + ) { + install(HttpTimeout) + } + try { + KtorAppleDeviceCheckClient( + client, + testJwtGenerator(), + AppleServiceEnvironment.PRODUCTION, + ).query(Base64.getEncoder().encodeToString(ByteArray(32))) shouldBe + DeviceCheckQuery.NotFound + } finally { + client.close() + } + } + + test("DeviceCheck maps request timeout to temporary unavailability") { + val client = HttpClient( + MockEngine { + delay(250) + respond( + content = """{"bit0":false,"bit1":false}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(HttpTimeout) + } + try { + shouldThrow { + KtorAppleDeviceCheckClient( + client, + testJwtGenerator(), + AppleServiceEnvironment.PRODUCTION, + timeoutMillis = 20, + ).query(Base64.getEncoder().encodeToString(ByteArray(32))) + } + } finally { + client.close() + } + } +}) + +private fun testJwtGenerator(): DeviceCheckJwtGenerator { + val keyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1")) + }.generateKeyPair() + val pem = """ + -----BEGIN PRIVATE KEY----- + ${Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(keyPair.private.encoded)} + -----END PRIVATE KEY----- + """.trimIndent() + return DeviceCheckJwtGenerator("TEAM", "KEY", pem) +} + +private class InMemoryTrialRepository( + private val events: MutableList? = null, +) : DeviceCheckTrialClaimRepository { + val claims = mutableMapOf() + + override suspend fun begin(tokenHash: String, accountId: UUID, now: Instant): BeginTrialClaim { + val existing = claims[tokenHash] + if (existing != null && existing.accountId != accountId) { + return BeginTrialClaim.ClaimedByAnotherAccount + } + val claim = existing ?: TrialClaim(tokenHash, accountId, TrialClaimStatus.RESERVED) + claims[tokenHash] = claim + return BeginTrialClaim.Owned(claim) + } + + override suspend fun transition(tokenHash: String, status: TrialClaimStatus, now: Instant) { + claims[tokenHash] = requireNotNull(claims[tokenHash]).copy(status = status) + events?.add(status.name) + } +} diff --git a/src/test/kotlin/com/osglab/account/features/integrity/IntegrityPortsTest.kt b/src/test/kotlin/com/osglab/account/features/integrity/IntegrityPortsTest.kt new file mode 100644 index 0000000..50da698 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/integrity/IntegrityPortsTest.kt @@ -0,0 +1,101 @@ +package com.osglab.account.features.integrity + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.time.Instant +import java.util.UUID + +class IntegrityPortsTest : FunSpec({ + test("unsupported promotional evidence is explicitly ineligible") { + val port = DefaultIntegrityRiskPort( + deviceCheckClient = unavailableDeviceCheck(), + appAttestRepository = EmptyAppAttestRepository, + ) + + port.assess( + IntegrityRiskRequest(UUID.randomUUID(), IntegrityRiskUseCase.SIGNUP_TRIAL), + ) shouldBe IntegrityRiskDecision( + IntegrityEligibility.INELIGIBLE, + IntegrityEvidenceState.UNSUPPORTED, + ) + } + + test("temporary DeviceCheck failure asks promotional caller to retry") { + val port = DefaultIntegrityRiskPort( + deviceCheckClient = unavailableDeviceCheck(), + appAttestRepository = EmptyAppAttestRepository, + ) + + port.assess( + IntegrityRiskRequest( + UUID.randomUUID(), + IntegrityRiskUseCase.SIGNUP_TRIAL, + deviceCheckToken = "token", + ), + ) shouldBe IntegrityRiskDecision( + IntegrityEligibility.RETRY_LATER, + IntegrityEvidenceState.TEMPORARILY_UNAVAILABLE, + ) + } + + test("trial and risk bits both deny another signup trial") { + suspend fun decision(bit0: Boolean, bit1: Boolean): IntegrityRiskDecision { + val port = DefaultIntegrityRiskPort( + deviceCheckClient = object : AppleDeviceCheckClient { + override suspend fun query(deviceToken: String) = + DeviceCheckQuery.Found(DeviceCheckState(bit0, bit1, null)) + + override suspend fun update( + deviceToken: String, + bit0: Boolean, + bit1: Boolean, + ) = Unit + }, + appAttestRepository = EmptyAppAttestRepository, + ) + return port.assess( + IntegrityRiskRequest( + UUID.randomUUID(), + IntegrityRiskUseCase.SIGNUP_TRIAL, + deviceCheckToken = "token", + ), + ) + } + + decision(bit0 = true, bit1 = false).eligibility shouldBe IntegrityEligibility.INELIGIBLE + decision(bit0 = false, bit1 = true).eligibility shouldBe IntegrityEligibility.INELIGIBLE + } +}) + +private fun unavailableDeviceCheck() = object : AppleDeviceCheckClient { + override suspend fun query(deviceToken: String): DeviceCheckQuery = + throw DeviceCheckUnavailableException("timeout") + + override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean): Unit = + throw DeviceCheckUnavailableException("timeout") +} + +private object EmptyAppAttestRepository : AppAttestRepository { + override suspend fun createChallenge(challenge: AppAttestChallenge) = Unit + + override suspend fun consumeChallenge( + id: UUID, + purpose: AppAttestChallengePurpose, + keyId: String, + challengeHash: String, + accountId: UUID?, + now: Instant, + ) = ConsumedChallenge.MissingOrMismatched + + override suspend fun saveKey(key: StoredAppAttestKey) = false + override suspend fun findKey(keyId: String): StoredAppAttestKey? = null + + override suspend fun updateCounter( + keyId: String, + expectedCounter: Long, + newCounter: Long, + now: Instant, + ) = false + + override suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant) = false +} diff --git a/src/test/kotlin/com/osglab/account/features/integrity/IntegrityServiceTest.kt b/src/test/kotlin/com/osglab/account/features/integrity/IntegrityServiceTest.kt new file mode 100644 index 0000000..d9f1e40 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/integrity/IntegrityServiceTest.kt @@ -0,0 +1,60 @@ +package com.osglab.account.features.integrity + +import com.osglab.account.common.errors.ExternalServiceUnavailableException +import com.osglab.account.common.errors.InvalidRequestException +import com.osglab.account.config.IntegrityConfig +import com.osglab.account.config.IntegrityPolicy +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec + +class IntegrityServiceTest : FunSpec({ + test("monitor policy fails open when Apple verification is unavailable") { + val service = service( + IntegrityPolicy.MONITOR, + IntegrityVerification.Unavailable("not configured"), + ) + + service.verifyAppleSignIn(IntegrityEvidence(), payload()) + } + + test("enforce policy fails closed when verification is unavailable") { + val service = service( + IntegrityPolicy.ENFORCE, + IntegrityVerification.Unavailable("network error"), + ) + + shouldThrow { + service.verifyAppleSignIn( + IntegrityEvidence(deviceCheckToken = "token"), + payload(), + ) + } + } + + test("cryptographically rejected evidence is never fail open") { + val service = service( + IntegrityPolicy.MONITOR, + IntegrityVerification.Rejected("invalid"), + ) + + shouldThrow { + service.verifyAppleSignIn( + IntegrityEvidence(deviceCheckToken = "token"), + payload(), + ) + } + } +}) + +private fun service( + devicePolicy: IntegrityPolicy, + deviceResult: IntegrityVerification, +): IntegrityService = IntegrityService( + config = IntegrityConfig(devicePolicy, IntegrityPolicy.MONITOR), + deviceCheckVerifier = object : DeviceCheckVerifier { + override suspend fun verify(deviceToken: String): IntegrityVerification = deviceResult + }, + appAttestVerifier = UnavailableAppAttestVerifier(), +) + +private fun payload() = AppleSignInIntegrityPayload("identity", "code", "nonce") diff --git a/src/test/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutesTest.kt new file mode 100644 index 0000000..4054446 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutesTest.kt @@ -0,0 +1,211 @@ +package com.osglab.account.features.inviteweb + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import kotlinx.coroutines.delay +import kotlin.test.Test + +class InviteWebRoutesTest { + private val config = InviteWebConfig( + appStoreUrl = "https://apps.apple.com/app/id1234567890", + universalLinkBaseUrl = "https://osglab.com/i", + appleAppId = "ABCDE12345.com.example.osg", + ) + + @Test + fun `valid referral renders a bilingual first-party page with hardened headers`() = testApplication { + application { + routing { + configureInviteWebRoutes(ReferralLookupPort { true }, config) + } + } + + val response = client.get("/i/$VALID_CODE") + val body = response.bodyAsText() + + response.status shouldBe HttpStatusCode.OK + response.headers[HttpHeaders.ContentType].orEmpty() shouldContain "text/html" + response.headers[HttpHeaders.CacheControl] shouldBe "no-store, max-age=0" + response.headers["Referrer-Policy"] shouldBe "no-referrer" + response.headers["X-Frame-Options"] shouldBe "DENY" + response.headers["X-Robots-Tag"] shouldBe "noindex, nofollow, noarchive" + response.headers["Content-Security-Policy"].orEmpty() shouldContain "script-src 'nonce-" + body shouldContain VALID_CODE + body shouldContain "复制邀请码" + body shouldContain "Copy invitation code" + body shouldContain "href=\"https://osglab.com/i/$VALID_CODE\"" + body shouldContain "https://apps.apple.com/app/id1234567890" + body shouldNotContain "analytics" + body shouldNotContain "googletag" + body shouldNotContain "firebase" + body shouldNotContain "branch.io" + body shouldNotContain "appsflyer" + body shouldNotContain "adjust.com" + } + + @Test + fun `malformed referral is rejected without invoking lookup`() = testApplication { + val lookedUpCodes = mutableListOf() + application { + routing { + configureInviteWebRoutes( + referralLookup = ReferralLookupPort { + lookedUpCodes += it + true + }, + config = config, + ) + } + } + + val response = client.get("/i/not-valid") + + response.status shouldBe HttpStatusCode.NotFound + response.headers[HttpHeaders.CacheControl] shouldBe "no-store, max-age=0" + lookedUpCodes shouldBe emptyList() + } + + @Test + fun `unknown referral returns the same generic not found response`() = testApplication { + application { + routing { + configureInviteWebRoutes(ReferralLookupPort { false }, config) + } + } + + val response = client.get("/i/$VALID_CODE") + + response.status shouldBe HttpStatusCode.NotFound + response.bodyAsText() shouldBe + "邀请链接无效或已失效 / This invitation link is invalid or expired" + } + + @Test + fun `lookup failure fails closed without exposing the exception`() = testApplication { + application { + routing { + configureInviteWebRoutes( + ReferralLookupPort { error("database password must not escape") }, + config, + ) + } + } + + val response = client.get("/i/$VALID_CODE") + val body = response.bodyAsText() + + response.status shouldBe HttpStatusCode.ServiceUnavailable + response.headers[HttpHeaders.RetryAfter] shouldBe "30" + body shouldNotContain "password" + body shouldContain "temporarily unavailable" + } + + @Test + fun `lookup timeout fails closed with retry guidance`() = testApplication { + application { + routing { + configureInviteWebRoutes( + referralLookup = ReferralLookupPort { + delay(250) + true + }, + config = config.copy(lookupTimeoutMillis = 100), + ) + } + } + + val response = client.get("/i/$VALID_CODE") + + response.status shouldBe HttpStatusCode.ServiceUnavailable + response.headers[HttpHeaders.RetryAfter] shouldBe "30" + } + + @Test + fun `both AASA paths return the same no-store JSON document`() = testApplication { + application { + routing { + configureInviteWebRoutes(ReferralLookupPort { true }, config) + } + } + + val wellKnown = client.get("/.well-known/apple-app-site-association") + val root = client.get("/apple-app-site-association") + + wellKnown.status shouldBe HttpStatusCode.OK + root.status shouldBe HttpStatusCode.OK + wellKnown.headers[HttpHeaders.ContentType].orEmpty() shouldContain "application/json" + wellKnown.headers[HttpHeaders.CacheControl] shouldBe "no-store, max-age=0" + wellKnown.bodyAsText() shouldBe root.bodyAsText() + wellKnown.bodyAsText() shouldContain "\"ABCDE12345.com.example.osg\"" + wellKnown.bodyAsText() shouldContain "\"/i/*\"" + } + + @Test + fun `configuration rejects unsafe URLs and malformed app IDs`() { + shouldThrow { + InviteWebConfig( + appStoreUrl = "http://apps.apple.com/app/id123", + appleAppId = "ABCDE12345.com.example.osg", + ) + } + shouldThrow { + InviteWebConfig( + appStoreUrl = "https://apps.apple.com/app/id123", + appleAppId = "ABCDE12345.com.example.osg", + universalLinkBaseUrl = "https://user@osglab.com/i", + ) + } + shouldThrow { + InviteWebConfig( + appStoreUrl = "https://example.com/fake-store", + appleAppId = "ABCDE12345.com.example.osg", + ) + } + shouldThrow { + InviteWebConfig( + appStoreUrl = "https://apps.apple.com/app/id123", + appleAppId = "ABCDE12345.com.example.osg", + universalLinkBaseUrl = "https://example.com/i", + ) + } + shouldThrow { + InviteWebConfig( + appStoreUrl = "https://apps.apple.com/app/id123", + appleAppId = "invalid", + ) + } + } + + @Test + fun `rendering safely encodes configured links and rejects path injection`() = testApplication { + val configWithQuery = config.copy( + appStoreUrl = "https://apps.apple.com/app/id1234567890?pt=1&ct=invite", + ) + application { + routing { + configureInviteWebRoutes(ReferralLookupPort { true }, configWithQuery) + } + } + + val response = client.get("/i/$VALID_CODE") + val body = response.bodyAsText() + + response.status shouldBe HttpStatusCode.OK + body shouldContain + "href=\"https://apps.apple.com/app/id1234567890?pt=1&ct=invite\"" + body shouldNotContain "?pt=1&ct=invite" + client.get("/i/${VALID_CODE}%2Ftracking").status shouldBe HttpStatusCode.NotFound + } + + private companion object { + const val VALID_CODE = "AbCdEf0123456789_-AbCd" + } +} diff --git a/src/test/kotlin/com/osglab/account/features/referrals/ReferralServiceTest.kt b/src/test/kotlin/com/osglab/account/features/referrals/ReferralServiceTest.kt new file mode 100644 index 0000000..dd47254 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/referrals/ReferralServiceTest.kt @@ -0,0 +1,206 @@ +package com.osglab.account.features.referrals + +import com.osglab.account.features.credits.TestBillingStore +import com.osglab.account.features.referrals.domain.InviteCodeGenerator +import com.osglab.account.features.referrals.domain.ReferralBindingRules +import com.osglab.account.features.referrals.domain.ReferralConflict +import com.osglab.account.features.referrals.domain.ReferralCampaign +import com.osglab.account.features.referrals.domain.ReferralCampaignBudget +import com.osglab.account.features.referrals.domain.ReferralCode +import com.osglab.account.features.referrals.domain.ReferralWindowExpired +import com.osglab.account.features.referrals.services.ReferralService +import com.osglab.account.features.referrals.services.ReferralRiskIdentity +import com.osglab.account.features.referrals.services.ReferralRiskProvider +import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger + +class ReferralServiceTest : FunSpec({ + val now = Instant.parse("2026-08-15T00:00:00Z") + + test("code creation is stable and code has non-enumerable length") { + val store = TestBillingStore() + val owner = UUID.randomUUID() + val service = referralService(store, now) { now.minus(Duration.ofDays(1)) } + + val first = service.getOrCreateCode(owner) + val second = service.getOrCreateCode(owner) + + second shouldBe first + first.code.length shouldBe 22 + store.codes.size shouldBe 1 + } + + test("an account binds once and repeated same binding is idempotent") { + val store = TestBillingStore() + val inviter = UUID.randomUUID() + val invitee = UUID.randomUUID() + var identityLookups = 0 + var registrationLookups = 0 + val service = referralService( + store = store, + now = now, + riskIdentity = { userId -> + identityLookups += 1 + ReferralRiskIdentity(fingerprint(userId), restricted = false) + }, + registeredAt = { + registrationLookups += 1 + now.minus(Duration.ofDays(1)) + }, + ) + val code = service.getOrCreateCode(inviter) + + val first = service.bind(invitee, code.code) + val identityLookupsAfterFirstBind = identityLookups + val registrationLookupsAfterFirstBind = registrationLookups + val second = service.bind(invitee, code.code) + + second shouldBe first + store.bindings.size shouldBe 1 + identityLookups shouldBe identityLookupsAfterFirstBind + registrationLookups shouldBe registrationLookupsAfterFirstBind + } + + test("self-referral and binding after the configured window are rejected") { + val store = TestBillingStore() + val owner = UUID.randomUUID() + val inWindow = referralService(store, now) { now.minus(Duration.ofDays(1)) } + val code = inWindow.getOrCreateCode(owner) + + shouldThrow { + inWindow.bind(owner, code.code) + } + + val expired = referralService(store, now) { now.minus(Duration.ofDays(8)) } + shouldThrow { + expired.bind(UUID.randomUUID(), code.code) + } + } + + test("identity tombstones prevent self-referral after account recreation") { + val store = TestBillingStore() + val deletedAccount = UUID.randomUUID() + val recreatedAccount = UUID.randomUUID() + val sharedFingerprint = "f".repeat(64) + val service = referralService( + store = store, + now = now, + registeredAt = { now.minus(Duration.ofDays(1)) }, + riskIdentity = { + ReferralRiskIdentity(sharedFingerprint, restricted = false) + }, + ) + val code = service.getOrCreateCode(deletedAccount) + + shouldThrow { + service.bind(recreatedAccount, code.code) + } + } + + test("campaign binding window overrides the legacy default window") { + val store = TestBillingStore() + val campaignId = UUID.randomUUID() + store.campaigns[campaignId] = ReferralCampaign( + id = campaignId, + name = "Short campaign", + startsAt = now.minusSeconds(60), + endsAt = now.plusSeconds(3_600), + bindingWindowSeconds = 3_600, + inviterRewardCredits = 10, + inviteeRewardCredits = 10, + maxRewardedBindings = 10, + budgetCredits = 200, + enabled = true, + ) + store.campaignBudgets[campaignId] = ReferralCampaignBudget(campaignId, 0, 0, now) + val service = referralService(store, now) { now.minus(Duration.ofHours(2)) } + val code = service.getOrCreateCode(UUID.randomUUID(), campaignId) + + shouldThrow { + service.bind(UUID.randomUUID(), code.code) + } + } + + test("campaign budget must cover one bilateral reward") { + shouldThrow { + ReferralCampaign( + id = UUID.randomUUID(), + name = "Underfunded", + startsAt = now.minusSeconds(1), + endsAt = null, + bindingWindowSeconds = 3_600, + inviterRewardCredits = 10, + inviteeRewardCredits = 10, + maxRewardedBindings = null, + budgetCredits = 19, + enabled = true, + ) + } + } + + test("binding rules include the exact deadline and detect recreated identities") { + val inviter = UUID.randomUUID() + val invitee = UUID.randomUUID() + val registeredAt = now.minus(Duration.ofDays(7)) + val fingerprint = "a".repeat(64) + val code = ReferralCode( + id = UUID.randomUUID(), + ownerUserId = inviter, + ownerIdentityFingerprint = fingerprint, + code = "abcdefghijklmnopqrstuv", + createdAt = registeredAt, + ) + + ReferralBindingRules.isWithinWindow( + registeredAt, + now, + Duration.ofDays(7), + ) shouldBe true + ReferralBindingRules.isWithinWindow( + registeredAt, + now.plusNanos(1), + Duration.ofDays(7), + ) shouldBe false + ReferralBindingRules.isSelfReferral(invitee, fingerprint, code) shouldBe true + ReferralBindingRules.isSelfReferral(invitee, "b".repeat(64), code) shouldBe false + ReferralBindingRules.isWithinWindow( + registeredAt = now, + attemptedAt = now.minusNanos(1), + bindingWindow = Duration.ofDays(7), + ) shouldBe false + } +}) + +private fun referralService( + store: TestBillingStore, + now: Instant, + riskIdentity: (UUID) -> ReferralRiskIdentity = { userId -> + ReferralRiskIdentity(fingerprint(userId), restricted = false) + }, + registeredAt: (UUID) -> Instant, +): ReferralService { + val sequence = AtomicInteger() + val generator = InviteCodeGenerator { + val suffix = sequence.incrementAndGet().toString().padStart(2, '0') + "abcdefghijklmnopqrst$suffix" + } + return ReferralService( + transactions = store, + registrationTimeProvider = UserRegistrationTimeProvider(registeredAt), + riskProvider = ReferralRiskProvider(riskIdentity), + bindingWindow = Duration.ofDays(7), + codeGenerator = generator, + clock = Clock.fixed(now, ZoneOffset.UTC), + ) +} + +private fun fingerprint(userId: UUID): String = + userId.toString().replace("-", "").repeat(2) diff --git a/src/test/kotlin/com/osglab/account/integration/MySqlSecurityIntegrationTest.kt b/src/test/kotlin/com/osglab/account/integration/MySqlSecurityIntegrationTest.kt new file mode 100644 index 0000000..24c0cc2 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/integration/MySqlSecurityIntegrationTest.kt @@ -0,0 +1,340 @@ +package com.osglab.account.integration + +import com.osglab.account.common.security.IdentityFingerprint +import com.osglab.account.common.security.SessionJwt +import com.osglab.account.config.DatabaseConfig +import com.osglab.account.config.DatabaseFactory +import com.osglab.account.config.SessionConfig +import com.osglab.account.features.account.ExposedAccountRepository +import com.osglab.account.features.auth.ExposedAuthRepository +import com.osglab.account.features.auth.SessionAccessAuthenticator +import com.osglab.account.features.credits.domain.CreditConflict +import com.osglab.account.features.credits.domain.UsageMeasurement +import com.osglab.account.features.credits.repositories.ExposedBillingTransactionRunner +import com.osglab.account.features.credits.services.CreditService +import com.osglab.account.features.credits.services.ReferralRewardConfig +import com.osglab.account.features.referrals.services.ReferralRiskIdentity +import com.osglab.account.features.referrals.services.ReferralRiskProvider +import com.osglab.account.features.referrals.services.ReferralService +import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.withClue +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.ints.shouldBeExactly +import io.kotest.matchers.longs.shouldBeExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import org.opentest4j.TestAbortedException +import org.testcontainers.DockerClientFactory +import org.testcontainers.containers.MySQLContainer +import java.sql.Connection +import java.sql.DriverManager +import java.time.Duration +import java.time.Instant +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger + +class MySqlSecurityIntegrationTest : FunSpec({ + test("migrations enforce deletion, session, ledger concurrency and referral idempotency") { + val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank) + if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) { + throw TestAbortedException("Docker is unavailable; MySQL integration test skipped") + } + + val mysql = if (externalJdbcUrl == null) { + KotlinMySqlContainer("mysql:8.4") + .withDatabaseName("osg_security_test") + .withUsername("test") + .withPassword("test") + .also(KotlinMySqlContainer::start) + } else { + null + } + val jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl + val username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank) + ?: mysql?.username + ?: "root" + val password = System.getenv("TEST_MYSQL_PASSWORD") + ?: mysql?.password + ?: "" + fun connection(): Connection = DriverManager.getConnection(jdbcUrl, username, password) + + val databaseConfig = DatabaseConfig( + jdbcUrl = jdbcUrl, + username = username, + password = password, + maximumPoolSize = 8, + ) + val factory = DatabaseFactory(databaseConfig) + try { + factory.database + val secondFactory = DatabaseFactory(databaseConfig) + try { + val inside = AtomicInteger() + val maximumInside = AtomicInteger() + coroutineScope { + listOf(factory, secondFactory).map { lockOwner -> + async(Dispatchers.Default) { + lockOwner.withMysqlNamedLock("integration-global-trial", 10) { + val current = inside.incrementAndGet() + maximumInside.updateAndGet { previous -> maxOf(previous, current) } + delay(100) + inside.decrementAndGet() + } + } + }.awaitAll() + } + maximumInside.get() shouldBeExactly 1 + } finally { + secondFactory.close() + } + val identity = IdentityFingerprint(ByteArray(32) { 7 }) + val authRepository = ExposedAuthRepository(factory) + val accountRepository = ExposedAccountRepository(factory, identity) + val sessionConfig = SessionConfig( + issuer = "https://issuer.example", + audience = "ios", + hmacSecret = ByteArray(32) { 4 }, + accessMinutes = 15, + refreshDays = 30, + ) + val sessionJwt = SessionJwt(sessionConfig) + val authenticator = SessionAccessAuthenticator(sessionJwt, authRepository) + + val deletedUser = UUID.randomUUID() + val deletedFamily = UUID.randomUUID() + val deletedSession = UUID.randomUUID() + val deletedFingerprint = identity.ofAppleSubject("deleted-apple-sub") + connection().use { connection -> + insertAccount(connection, deletedUser, "deleted-apple-sub", deletedFingerprint) + connection.createStatement().use { statement -> + statement.executeUpdate( + """ + INSERT INTO sessions ( + id, account_id, family_id, refresh_token_hash, created_at, expires_at + ) VALUES ( + '$deletedSession', '$deletedUser', '$deletedFamily', + '${"1".repeat(64)}', CURRENT_TIMESTAMP(6), + DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL 1 DAY) + ) + """.trimIndent(), + ) + statement.executeUpdate( + "INSERT INTO credit_accounts (user_id, balance) VALUES ('$deletedUser', 10)", + ) + statement.executeUpdate( + """ + INSERT INTO credit_ledger ( + id, user_id, entry_type, amount_delta, balance_after, idempotency_key + ) VALUES ( + '${UUID.randomUUID()}', '$deletedUser', 'MANUAL_GRANT', 10, 10, + 'integration-ledger-retained' + ) + """.trimIndent(), + ) + statement.executeUpdate( + """ + INSERT INTO referral_codes ( + id, owner_user_id, owner_identity_fingerprint, code + ) VALUES ( + '${UUID.randomUUID()}', '$deletedUser', '$deletedFingerprint', + 'abcdefghijklmnopqrstuv' + ) + """.trimIndent(), + ) + statement.executeUpdate( + """ + INSERT INTO gateway_grants ( + id, account_id, idempotency_key, expires_at, created_at, updated_at + ) VALUES ( + '${UUID.randomUUID()}', '$deletedUser', 'integration-delete-grant', + DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL 1 DAY), + CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6) + ) + """.trimIndent(), + ) + } + } + val accessToken = sessionJwt.issue(deletedUser, deletedSession).value + authenticator.authenticate(accessToken).shouldNotBeNull() + + val deletedAt = Instant.now() + accountRepository.deleteById( + deletedUser, + deletedAt, + deletedAt.plus(Duration.ofDays(365)), + createRevocation = { null }, + ) + + authenticator.authenticate(accessToken).shouldBeNull() + connection().use { connection -> + count(connection, "accounts", "id = '$deletedUser'") shouldBeExactly 0 + count(connection, "sessions", "account_id = '$deletedUser'") shouldBeExactly 0 + count(connection, "credit_accounts", "user_id = '$deletedUser'") shouldBeExactly 0 + count(connection, "referral_codes", "owner_user_id = '$deletedUser'") shouldBeExactly 0 + count(connection, "gateway_grants", "account_id = '$deletedUser'") shouldBeExactly 0 + count(connection, "credit_ledger", "user_id = '$deletedUser'") shouldBeExactly 1 + count( + connection, + "account_identity_tombstones", + "identity_fingerprint = '$deletedFingerprint'", + ) shouldBeExactly 1 + } + + val rateId = UUID.randomUUID() + connection().use { connection -> + connection.createStatement().use { statement -> + statement.executeUpdate( + """ + INSERT INTO credit_rate_versions ( + id, kind, provider, model, effective_from, + asr_credits_numerator, asr_millis_denominator + ) VALUES ( + '$rateId', 'ASR', 'integration-provider', 'integration-model', + DATE_SUB(CURRENT_TIMESTAMP(6), INTERVAL 1 MINUTE), 1, 100 + ) + """.trimIndent(), + ) + } + } + val transactions = ExposedBillingTransactionRunner(factory.database) + val credits = CreditService( + transactions, + ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30), + ) + val concurrentUser = UUID.randomUUID() + connection().use { + insertAccount(it, concurrentUser, "concurrent-sub", identity.ofAppleSubject("concurrent-sub")) + } + credits.grantSignupTrial(concurrentUser, 100, "integration-signup-concurrent") + val reservationResults = coroutineScope { + listOf("integration-reserve-one", "integration-reserve-two").map { key -> + async(Dispatchers.Default) { + runCatching { + credits.reserve( + concurrentUser, + "integration-provider", + "integration-model", + UsageMeasurement.Asr(6_000), + managedCall = true, + idempotencyKey = key, + ) + } + } + }.awaitAll() + } + withClue( + reservationResults.joinToString { result -> + result.exceptionOrNull()?.let { "${it::class.simpleName}: ${it.message}" } ?: "success" + }, + ) { + reservationResults.count { it.isSuccess } shouldBeExactly 1 + } + credits.getAccount(concurrentUser).balance shouldBeExactly 40 + + val inviter = UUID.randomUUID() + val invitee = UUID.randomUUID() + connection().use { + insertAccount(it, inviter, "inviter-sub", identity.ofAppleSubject("inviter-sub")) + insertAccount(it, invitee, "invitee-sub", identity.ofAppleSubject("invitee-sub")) + } + val referrals = ReferralService( + transactions = transactions, + registrationTimeProvider = UserRegistrationTimeProvider { accountId -> + accountRepository.findById(accountId)?.createdAt + }, + riskProvider = ReferralRiskProvider { accountId -> + accountRepository.findById(accountId)?.let { + ReferralRiskIdentity(it.identityFingerprint, it.antiAbuseRestricted) + } + }, + bindingWindow = Duration.ofDays(7), + ) + val code = referrals.getOrCreateCode(inviter) + referrals.bind(invitee, code.code) + credits.grantSignupTrial(invitee, 100, "integration-signup-invitee") + val referralReservation = credits.reserve( + invitee, + "integration-provider", + "integration-model", + UsageMeasurement.Asr(1_000), + managedCall = true, + idempotencyKey = "integration-referral-reserve", + ) + coroutineScope { + List(2) { + async(Dispatchers.Default) { + credits.settle( + invitee, + referralReservation.id, + UsageMeasurement.Asr(500), + "integration-referral-settle", + ) + } + }.awaitAll() + } + shouldThrow { + credits.settle( + invitee, + referralReservation.id, + UsageMeasurement.Asr(600), + "integration-referral-settle", + ) + } + connection().use { connection -> + count( + connection, + "credit_ledger", + "entry_type IN ('REFERRAL_INVITER', 'REFERRAL_INVITEE')", + ) shouldBeExactly 2 + count( + connection, + "referral_bindings", + "invitee_user_id = '$invitee' AND rewarded_at IS NOT NULL", + ) shouldBeExactly 1 + } + } finally { + factory.close() + mysql?.stop() + } + } +}) + +private class KotlinMySqlContainer(image: String) : + MySQLContainer(image) + +private fun KotlinMySqlContainer.connection(): Connection = + DriverManager.getConnection(jdbcUrl, username, password) + +private fun insertAccount( + connection: Connection, + id: UUID, + appleSubject: String, + identityFingerprint: String, +) { + connection.createStatement().use { statement -> + statement.executeUpdate( + """ + INSERT INTO accounts ( + id, apple_sub, identity_fingerprint, anti_abuse_restricted, created_at, updated_at + ) VALUES ( + '$id', '$appleSubject', '$identityFingerprint', FALSE, + CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6) + ) + """.trimIndent(), + ) + } +} + +private fun count(connection: Connection, table: String, predicate: String): Int = + connection.createStatement().use { statement -> + statement.executeQuery("SELECT COUNT(*) FROM $table WHERE $predicate").use { result -> + result.next() + result.getInt(1) + } + }