Add anonymous OOBE gateway grants
Provide App Attest-bound, one-time onboarding AI access without creating accounts, with durable replay protection and production deployment safeguards.
This commit is contained in:
@@ -36,6 +36,15 @@ class AppConfigTest : FunSpec({
|
||||
config.environment shouldBe Environment.PRODUCTION
|
||||
config.database.username shouldBe "test"
|
||||
config.database.migrationUsername shouldBe "test_migrator"
|
||||
config.integrity.allowDevelopmentAppAttest shouldBe false
|
||||
}
|
||||
|
||||
test("production can explicitly allow development App Attest builds") {
|
||||
val config = validProductionConfig().apply {
|
||||
put("app.integrity.allowDevelopmentAppAttest", "true")
|
||||
}
|
||||
|
||||
AppConfig.from(config).integrity.allowDevelopmentAppAttest shouldBe true
|
||||
}
|
||||
|
||||
test("production accepts enabled admin bootstrap with Argon2 PHC hash") {
|
||||
|
||||
@@ -378,6 +378,8 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
|
||||
"/v1/integrity/challenges",
|
||||
"/v1/integrity/attest",
|
||||
"/v1/integrity/assert",
|
||||
"/v1/oobe/grants",
|
||||
"/v1/oobe/grants/refresh",
|
||||
"/v1/gateway/catalog",
|
||||
"/v1/gateway/grants",
|
||||
"/v1/gateway/grants/refresh",
|
||||
|
||||
@@ -37,7 +37,7 @@ class SmokeDeploymentTest : FunSpec({
|
||||
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
|
||||
runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/"
|
||||
runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/"
|
||||
runner shouldContain "Flyway history was not exactly successful V1-V17"
|
||||
runner shouldContain "Flyway history was not exactly successful V1-V26"
|
||||
runner shouldContain "default referral rewards were not 1000 credits for both accounts"
|
||||
runner shouldContain "active smaller credit rates did not match the V10 contract"
|
||||
runner shouldContain "first ledger page omitted nextCursor"
|
||||
|
||||
@@ -72,6 +72,23 @@ class AppAttestCryptoTest : FunSpec({
|
||||
}
|
||||
}
|
||||
|
||||
test("production can explicitly allow development App Attest builds") {
|
||||
val fixture = AppAttestFixture()
|
||||
val developmentAaguid = "appattestdevelop".toByteArray(Charsets.US_ASCII)
|
||||
val crypto = fixture.crypto(
|
||||
nonce = fixture.expectedNonce(developmentAaguid),
|
||||
allowDevelopment = true,
|
||||
)
|
||||
|
||||
val material = crypto.validateAttestation(
|
||||
fixture.attestationObject(aaguid = developmentAaguid),
|
||||
fixture.keyId,
|
||||
fixture.challenge,
|
||||
)
|
||||
|
||||
material.publicKey shouldBe fixture.keyPair.public.encoded
|
||||
}
|
||||
|
||||
test("assertion verifies ECDSA and requires a strictly increasing counter") {
|
||||
val fixture = AppAttestFixture()
|
||||
val hash = sha256ForTest("cost-request".toByteArray())
|
||||
@@ -139,12 +156,16 @@ private class AppAttestFixture {
|
||||
sha256ForTest(uncompressedPointForTest(keyPair.public as ECPublicKey)),
|
||||
)
|
||||
|
||||
fun crypto(nonce: ByteArray = expectedNonce()): LibraryAppAttestCrypto =
|
||||
fun crypto(
|
||||
nonce: ByteArray = expectedNonce(),
|
||||
allowDevelopment: Boolean = false,
|
||||
): LibraryAppAttestCrypto =
|
||||
LibraryAppAttestCrypto(
|
||||
IntegrityConfig(
|
||||
deviceCheckPolicy = IntegrityPolicy.ENFORCE,
|
||||
appAttestPolicy = IntegrityPolicy.ENFORCE,
|
||||
appleEnvironment = AppleServiceEnvironment.PRODUCTION,
|
||||
allowDevelopmentAppAttest = allowDevelopment,
|
||||
),
|
||||
AppAttestCertificateValidator {
|
||||
ValidatedAppAttestCertificate(keyPair.public as ECPublicKey, nonce)
|
||||
@@ -189,8 +210,8 @@ private class AppAttestFixture {
|
||||
.EncodeToBytes()
|
||||
}
|
||||
|
||||
private fun expectedNonce(): ByteArray =
|
||||
sha256ForTest(attestationAuthData(rpIdHash, productionAaguid()) + sha256ForTest(challenge))
|
||||
fun expectedNonce(aaguid: ByteArray = productionAaguid()): ByteArray =
|
||||
sha256ForTest(attestationAuthData(rpIdHash, aaguid) + sha256ForTest(challenge))
|
||||
|
||||
private fun productionAaguid(): ByteArray =
|
||||
"appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.osglab.account.features.oobe
|
||||
|
||||
import com.osglab.account.features.gateway.models.GatewayCapability
|
||||
import com.osglab.account.features.gateway.models.GatewayModelProfile
|
||||
import com.osglab.account.features.gateway.models.GatewayPrincipal
|
||||
import com.osglab.account.features.gateway.models.GatewayReasoningEffort
|
||||
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
|
||||
import com.osglab.account.features.gateway.models.GatewaySubjectType
|
||||
import com.osglab.account.features.gateway.models.GatewayTaskExecutionPolicy
|
||||
import com.osglab.account.features.gateway.models.GatewayTaskKind
|
||||
import com.osglab.account.features.gateway.models.GatewayThinkingMode
|
||||
import com.osglab.account.features.gateway.models.GatewayToolsMode
|
||||
import com.osglab.account.features.gateway.models.GatewayWebSearchMode
|
||||
import com.osglab.account.features.gateway.models.OobeFeature
|
||||
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.CreditReservation
|
||||
import com.osglab.account.features.gateway.ports.CreditReservationPort
|
||||
import com.osglab.account.features.gateway.ports.GatewayGrantPort
|
||||
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.GatewayAccessDeniedException
|
||||
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.shouldBe
|
||||
import java.time.Instant
|
||||
|
||||
class OobeGatewayServiceTest : StringSpec({
|
||||
"executes each fixed OOBE feature once without touching credits or account audit" {
|
||||
val credits = CountingCredits()
|
||||
val oobe = FakeOobeExecutionRepository()
|
||||
val service = service(credits, oobe)
|
||||
|
||||
OobeFeature.entries.forEachIndexed { index, feature ->
|
||||
service.execute(OOBE_PRINCIPAL, request(feature, "oobe-feature-$index"), DISCARD)
|
||||
}
|
||||
|
||||
credits.calls shouldBe 0
|
||||
oobe.consumed.map(OobeRequestClaim::feature).toSet() shouldBe OobeFeature.entries.toSet()
|
||||
}
|
||||
|
||||
"rejects a fifth call and a repeated feature without paid fallback" {
|
||||
val credits = CountingCredits()
|
||||
val oobe = FakeOobeExecutionRepository()
|
||||
val service = service(credits, oobe)
|
||||
OobeFeature.entries.forEachIndexed { index, feature ->
|
||||
service.execute(OOBE_PRINCIPAL, request(feature, "oobe-once-$index"), DISCARD)
|
||||
}
|
||||
|
||||
shouldThrow<OobeFeatureAlreadyUsedException> {
|
||||
service.execute(OOBE_PRINCIPAL, request(OobeFeature.ASK_AI, "oobe-fifth-call"), DISCARD)
|
||||
}
|
||||
credits.calls shouldBe 0
|
||||
}
|
||||
|
||||
"releases the feature claim when the provider fails" {
|
||||
val credits = CountingCredits()
|
||||
val oobe = FakeOobeExecutionRepository()
|
||||
val service = service(credits, oobe, fail = true)
|
||||
|
||||
shouldThrow<ProviderFailure> {
|
||||
service.execute(OOBE_PRINCIPAL, request(OobeFeature.VOICE_INPUT, "oobe-provider-fail"), DISCARD)
|
||||
}
|
||||
|
||||
oobe.released.map(OobeRequestClaim::feature) shouldBe listOf(OobeFeature.VOICE_INPUT)
|
||||
credits.calls shouldBe 0
|
||||
}
|
||||
|
||||
"enforces token boundary and exact feature mapping" {
|
||||
val credits = CountingCredits()
|
||||
val service = service(credits, FakeOobeExecutionRepository())
|
||||
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
service.execute(
|
||||
OOBE_PRINCIPAL,
|
||||
request(OobeFeature.ASK_AI, "oobe-wrong-map").copy(
|
||||
executionPolicy = policy(GatewayTaskKind.CLIPBOARD_TRANSFORM),
|
||||
),
|
||||
DISCARD,
|
||||
)
|
||||
}
|
||||
shouldThrow<GatewayAccessDeniedException> {
|
||||
service.execute(
|
||||
ACCOUNT_PRINCIPAL,
|
||||
request(OobeFeature.ASK_AI, "account-oobe-feature"),
|
||||
DISCARD,
|
||||
)
|
||||
}
|
||||
credits.calls shouldBe 0
|
||||
}
|
||||
})
|
||||
|
||||
private fun service(
|
||||
credits: CountingCredits,
|
||||
oobe: OobeRepository,
|
||||
fail: Boolean = false,
|
||||
): GatewayService = GatewayService(
|
||||
catalog = ProviderCatalog(listOf(FakeOobeProvider(fail))),
|
||||
credits = credits,
|
||||
grants = GatewayGrantPort { _, _ -> error("account grant lookup must not run for OOBE") },
|
||||
usageRecords = NoAccountUsage,
|
||||
oobeRequests = oobe,
|
||||
)
|
||||
|
||||
private class FakeOobeProvider(private val fail: Boolean) : GatewayProvider {
|
||||
override val descriptor = ProviderDescriptor(
|
||||
id = "oobe-test-provider",
|
||||
capabilities = setOf(GatewayCapability.POLISH, GatewayCapability.AI),
|
||||
streaming = false,
|
||||
usageMeter = UsageMeter.LLM_TOKEN,
|
||||
)
|
||||
|
||||
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
|
||||
if (fail) throw ProviderFailure()
|
||||
return ProviderUsage(
|
||||
meter = UsageMeter.LLM_TOKEN,
|
||||
units = 2,
|
||||
inputUnits = 1,
|
||||
outputUnits = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class CountingCredits : CreditReservationPort {
|
||||
var calls = 0
|
||||
|
||||
override suspend fun reserve(
|
||||
accountId: String,
|
||||
meter: UsageMeter,
|
||||
estimatedUnits: Long,
|
||||
requestId: String,
|
||||
): CreditReservation {
|
||||
calls += 1
|
||||
error("credits must not be called")
|
||||
}
|
||||
|
||||
override suspend fun settle(reservationId: String, actualUnits: Long) {
|
||||
calls += 1
|
||||
error("credits must not be called")
|
||||
}
|
||||
|
||||
override suspend fun release(reservationId: String) {
|
||||
calls += 1
|
||||
error("credits must not be called")
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeOobeExecutionRepository : OobeRepository {
|
||||
private val claimedFeatures = mutableSetOf<OobeFeature>()
|
||||
val consumed = mutableListOf<OobeRequestClaim>()
|
||||
val released = mutableListOf<OobeRequestClaim>()
|
||||
|
||||
override suspend fun claim(
|
||||
request: OobeProviderRequest,
|
||||
expiresAt: Instant,
|
||||
now: Instant,
|
||||
): OobeRequestClaim? {
|
||||
if (!claimedFeatures.add(request.feature)) return null
|
||||
return OobeRequestClaim(request.subjectId, request.feature, request.requestId)
|
||||
}
|
||||
|
||||
override suspend fun markStarted(claim: OobeRequestClaim) = Unit
|
||||
|
||||
override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) {
|
||||
consumed += claim
|
||||
}
|
||||
|
||||
override suspend fun release(claim: OobeRequestClaim, errorCode: String) {
|
||||
claimedFeatures -= claim.feature
|
||||
released += claim
|
||||
}
|
||||
|
||||
override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) = Unit
|
||||
|
||||
override suspend fun findOrCreateSubject(
|
||||
keyId: String,
|
||||
installationHash: String,
|
||||
subjectId: String,
|
||||
now: Instant,
|
||||
): OobeSubject = error("not used")
|
||||
|
||||
override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh = error("not used")
|
||||
|
||||
override suspend fun rotateRefresh(
|
||||
currentTokenHash: String,
|
||||
rotationIdempotencyKey: String,
|
||||
newTokenId: String,
|
||||
newTokenHash: String,
|
||||
newExpiresAt: Instant,
|
||||
now: Instant,
|
||||
): OobeRefreshRotationResult = error("not used")
|
||||
|
||||
override suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant? =
|
||||
error("not used")
|
||||
}
|
||||
|
||||
private object NoAccountUsage : GatewayUsagePort {
|
||||
override suspend fun claim(metadata: ProviderRequestMetadata) = error("account audit must not be called")
|
||||
override suspend fun markStarted(accountId: String, requestId: String) = error("account audit must not be called")
|
||||
override suspend fun markSettlementPending(
|
||||
accountId: String,
|
||||
requestId: String,
|
||||
usage: ProviderUsage,
|
||||
) = error("account audit must not be called")
|
||||
|
||||
override suspend fun markSucceeded(accountId: String, requestId: String, usage: ProviderUsage) =
|
||||
error("account audit must not be called")
|
||||
|
||||
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) =
|
||||
error("account audit must not be called")
|
||||
|
||||
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) =
|
||||
error("account audit must not be called")
|
||||
|
||||
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> = emptyList()
|
||||
}
|
||||
|
||||
private fun request(feature: OobeFeature, requestId: String): TextProviderRequest {
|
||||
val mapping = OobeContract.policy(feature)
|
||||
return TextProviderRequest(
|
||||
requestId = requestId,
|
||||
capability = mapping.capability,
|
||||
executionPolicy = policy(mapping.taskKind),
|
||||
input = "hello",
|
||||
context = null,
|
||||
maxOutputTokens = 1,
|
||||
temperature = 0.0,
|
||||
stream = false,
|
||||
requestPurpose = GatewayRequestPurpose.OOBE,
|
||||
oobeFeature = feature,
|
||||
)
|
||||
}
|
||||
|
||||
private fun policy(taskKind: GatewayTaskKind) = GatewayTaskExecutionPolicy(
|
||||
taskKind = taskKind,
|
||||
modelProfile = GatewayModelProfile.LOW_LATENCY,
|
||||
thinking = GatewayThinkingMode.DISABLED,
|
||||
reasoningEffort = null as GatewayReasoningEffort?,
|
||||
webSearch = GatewayWebSearchMode.DISABLED,
|
||||
tools = GatewayToolsMode.DISABLED,
|
||||
allowEmptyContentRetry = false,
|
||||
maxOutputTokens = 1,
|
||||
)
|
||||
|
||||
private val OOBE_PRINCIPAL = GatewayPrincipal(
|
||||
userId = "20000000-0000-0000-0000-000000000001",
|
||||
grantId = "30000000-0000-0000-0000-000000000001",
|
||||
scopes = OobeContract.scopes,
|
||||
subjectType = GatewaySubjectType.OOBE,
|
||||
)
|
||||
private val ACCOUNT_PRINCIPAL = GatewayPrincipal(
|
||||
userId = "40000000-0000-0000-0000-000000000001",
|
||||
scopes = OobeContract.scopes,
|
||||
)
|
||||
private val DISCARD = ProviderOutput {}
|
||||
private class ProviderFailure : RuntimeException()
|
||||
@@ -0,0 +1,299 @@
|
||||
package com.osglab.account.features.oobe
|
||||
|
||||
import com.nimbusds.jwt.SignedJWT
|
||||
import com.nimbusds.jwt.JWTClaimsSet
|
||||
import com.nimbusds.jose.JWSAlgorithm
|
||||
import com.nimbusds.jose.JWSHeader
|
||||
import com.nimbusds.jose.crypto.MACSigner
|
||||
import com.osglab.account.config.AppleServiceEnvironment
|
||||
import com.osglab.account.config.IntegrityConfig
|
||||
import com.osglab.account.config.IntegrityPolicy
|
||||
import com.osglab.account.features.gateway.models.GatewaySubjectType
|
||||
import com.osglab.account.features.gateway.models.ProviderUsage
|
||||
import com.osglab.account.features.integrity.AppAttestChallenge
|
||||
import com.osglab.account.features.integrity.AppAttestChallengePurpose
|
||||
import com.osglab.account.features.integrity.AppAttestCrypto
|
||||
import com.osglab.account.features.integrity.AppAttestKeyStatus
|
||||
import com.osglab.account.features.integrity.AppAttestRepository
|
||||
import com.osglab.account.features.integrity.AppAttestService
|
||||
import com.osglab.account.features.integrity.AppAttestRejectedException
|
||||
import com.osglab.account.features.integrity.AttestedKeyMaterial
|
||||
import com.osglab.account.features.integrity.ConsumedChallenge
|
||||
import com.osglab.account.features.integrity.StoredAppAttestKey
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.StringSpec
|
||||
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.security.MessageDigest
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
class OobeGrantServiceTest : StringSpec({
|
||||
"canonical assertion is server-owned and binds all fixed permissions" {
|
||||
val challenge = ByteArray(32) { it.toByte() }
|
||||
val payload = OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID).decodeToString()
|
||||
|
||||
payload shouldBe """
|
||||
osg-app-attest-v1
|
||||
purpose=oobe-gateway-grant
|
||||
challenge=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
|
||||
key_id=$KEY_ID
|
||||
installation_id=$INSTALLATION_ID
|
||||
scopes=ai,polish
|
||||
features=ask_ai,clipboard_reply,clipboard_translate,voice_input
|
||||
grant_ttl_seconds=1800
|
||||
access_ttl_seconds=300
|
||||
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
"issues a distinct short-lived OOBE token after the canonical assertion" {
|
||||
val clock = MutableClock(NOW)
|
||||
val challenge = ByteArray(32) { 7 }
|
||||
val expectedHash = sha256(
|
||||
OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID),
|
||||
)
|
||||
val repository = FakeOobeRepository()
|
||||
val service = service(repository, expectedHash, clock)
|
||||
|
||||
val tokens = service.create(request(challenge))
|
||||
val jwt = SignedJWT.parse(tokens.accessToken).jwtClaimsSet
|
||||
|
||||
jwt.getStringClaim("typ") shouldBe "oobe_gateway_access"
|
||||
jwt.subject.startsWith("oobe:") shouldBe true
|
||||
jwt.getStringListClaim("scp").shouldContainExactlyInAnyOrder("polish", "ai")
|
||||
jwt.getStringListClaim("features").shouldContainExactlyInAnyOrder(
|
||||
"voice_input",
|
||||
"clipboard_translate",
|
||||
"clipboard_reply",
|
||||
"ask_ai",
|
||||
)
|
||||
Duration.between(jwt.issueTime.toInstant(), jwt.expirationTime.toInstant()) shouldBe
|
||||
Duration.ofMinutes(5)
|
||||
Duration.between(NOW, Instant.parse(tokens.refreshExpiresAt)) shouldBe Duration.ofMinutes(30)
|
||||
service.authenticate(tokens.accessToken)?.subjectType shouldBe GatewaySubjectType.OOBE
|
||||
|
||||
val overScoped = SignedJWT(
|
||||
JWSHeader(JWSAlgorithm.HS256),
|
||||
JWTClaimsSet.Builder(jwt)
|
||||
.claim("scp", listOf("ai", "agent", "polish"))
|
||||
.build(),
|
||||
).apply { sign(MACSigner(ByteArray(32) { 1 })) }.serialize()
|
||||
service.authenticate(overScoped) shouldBe null
|
||||
|
||||
val accountTyped = SignedJWT(
|
||||
JWSHeader(JWSAlgorithm.HS256),
|
||||
JWTClaimsSet.Builder(jwt)
|
||||
.claim("typ", "gateway_access")
|
||||
.build(),
|
||||
).apply { sign(MACSigner(ByteArray(32) { 1 })) }.serialize()
|
||||
service.authenticate(accountTyped) shouldBe null
|
||||
|
||||
clock.now = NOW.plus(Duration.ofMinutes(5))
|
||||
service.authenticate(tokens.accessToken) shouldBe null
|
||||
}
|
||||
|
||||
"rejects an assertion generated for a different installation payload" {
|
||||
val challenge = ByteArray(32) { 9 }
|
||||
val signedHash = sha256(
|
||||
OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID),
|
||||
)
|
||||
val service = service(FakeOobeRepository(), signedHash, MutableClock(NOW))
|
||||
|
||||
shouldThrow<AppAttestRejectedException> {
|
||||
service.create(request(challenge).copy(installationId = UUID.randomUUID().toString()))
|
||||
}
|
||||
}
|
||||
|
||||
"refresh cannot extend the original grant TTL" {
|
||||
val clock = MutableClock(NOW)
|
||||
val challenge = ByteArray(32) { 5 }
|
||||
val expectedHash = sha256(
|
||||
OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID),
|
||||
)
|
||||
val service = service(FakeOobeRepository(), expectedHash, clock)
|
||||
val created = service.create(request(challenge))
|
||||
|
||||
clock.now = NOW.plus(Duration.ofMinutes(29))
|
||||
val refreshed = service.refresh(created.refreshToken, "oobe-refresh-1")
|
||||
|
||||
refreshed.refreshExpiresAt shouldBe created.refreshExpiresAt
|
||||
refreshed.accessExpiresAt shouldBe NOW.plus(Duration.ofMinutes(30)).toString()
|
||||
}
|
||||
})
|
||||
|
||||
private fun service(
|
||||
repository: OobeRepository,
|
||||
expectedHash: ByteArray,
|
||||
clock: Clock,
|
||||
): OobeGrantService {
|
||||
val appAttestRepository = FakeAppAttestRepository()
|
||||
val appAttest = AppAttestService(
|
||||
repository = appAttestRepository,
|
||||
crypto = HashCheckingAppAttestCrypto(expectedHash),
|
||||
config = IntegrityConfig(
|
||||
deviceCheckPolicy = IntegrityPolicy.ENFORCE,
|
||||
appAttestPolicy = IntegrityPolicy.ENFORCE,
|
||||
appleEnvironment = AppleServiceEnvironment.PRODUCTION,
|
||||
),
|
||||
clock = clock,
|
||||
)
|
||||
return OobeGrantService(
|
||||
repository = repository,
|
||||
appAttest = appAttest,
|
||||
settings = OobeTokenSettings(
|
||||
issuer = "osg-test",
|
||||
audience = "osg-gateway-test",
|
||||
accessTokenHmacSecret = ByteArray(32) { 1 },
|
||||
refreshTokenHmacSecret = ByteArray(32) { 2 },
|
||||
),
|
||||
clock = clock,
|
||||
)
|
||||
}
|
||||
|
||||
private class HashCheckingAppAttestCrypto(
|
||||
private val expectedHash: ByteArray,
|
||||
) : AppAttestCrypto {
|
||||
override suspend fun validateAttestation(
|
||||
attestationObject: ByteArray,
|
||||
keyId: String,
|
||||
challenge: ByteArray,
|
||||
): AttestedKeyMaterial = error("not used")
|
||||
|
||||
override suspend fun validateAssertion(
|
||||
assertionObject: ByteArray,
|
||||
clientDataHash: ByteArray,
|
||||
publicKey: ByteArray,
|
||||
lastCounter: Long,
|
||||
): Long {
|
||||
if (!MessageDigest.isEqual(clientDataHash, expectedHash)) {
|
||||
throw AppAttestRejectedException("canonical payload mismatch")
|
||||
}
|
||||
return lastCounter + 1
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeAppAttestRepository : AppAttestRepository {
|
||||
private var counter = 0L
|
||||
|
||||
override suspend fun createChallenge(challenge: AppAttestChallenge) = Unit
|
||||
|
||||
override suspend fun consumeChallenge(
|
||||
id: UUID,
|
||||
purpose: AppAttestChallengePurpose,
|
||||
keyId: String,
|
||||
challengeHash: String,
|
||||
accountId: UUID?,
|
||||
now: Instant,
|
||||
): ConsumedChallenge = ConsumedChallenge.Valid
|
||||
|
||||
override suspend fun saveKey(key: StoredAppAttestKey): Boolean = true
|
||||
|
||||
override suspend fun findKey(keyId: String): StoredAppAttestKey =
|
||||
StoredAppAttestKey(
|
||||
keyId = keyId,
|
||||
publicKey = byteArrayOf(1),
|
||||
receipt = byteArrayOf(1),
|
||||
counter = counter,
|
||||
accountId = null,
|
||||
status = AppAttestKeyStatus.ACTIVE,
|
||||
)
|
||||
|
||||
override suspend fun updateCounter(
|
||||
keyId: String,
|
||||
expectedCounter: Long,
|
||||
newCounter: Long,
|
||||
now: Instant,
|
||||
): Boolean {
|
||||
if (expectedCounter != counter || newCounter <= counter) return false
|
||||
counter = newCounter
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant): Boolean = true
|
||||
}
|
||||
|
||||
private class FakeOobeRepository : OobeRepository {
|
||||
private val subjects = mutableMapOf<Pair<String, String>, OobeSubject>()
|
||||
private val grants = mutableMapOf<String, OobeGrant>()
|
||||
private val refreshes = mutableMapOf<String, StoredOobeRefresh>()
|
||||
|
||||
override suspend fun findOrCreateSubject(
|
||||
keyId: String,
|
||||
installationHash: String,
|
||||
subjectId: String,
|
||||
now: Instant,
|
||||
): OobeSubject = subjects.getOrPut(keyId to installationHash) {
|
||||
OobeSubject(subjectId, keyId, installationHash)
|
||||
}
|
||||
|
||||
override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh {
|
||||
grants[grant.grant.id] = grant.grant
|
||||
return StoredOobeRefresh(
|
||||
grant.grant,
|
||||
grant.refreshTokenId,
|
||||
grant.refreshFamilyId,
|
||||
grant.refreshExpiresAt,
|
||||
).also { refreshes[grant.refreshTokenHash] = it }
|
||||
}
|
||||
|
||||
override suspend fun rotateRefresh(
|
||||
currentTokenHash: String,
|
||||
rotationIdempotencyKey: String,
|
||||
newTokenId: String,
|
||||
newTokenHash: String,
|
||||
newExpiresAt: Instant,
|
||||
now: Instant,
|
||||
): OobeRefreshRotationResult {
|
||||
val current = refreshes[currentTokenHash] ?: return OobeRefreshRotationResult.Invalid
|
||||
if (!current.expiresAt.isAfter(now) || !current.grant.expiresAt.isAfter(now)) {
|
||||
return OobeRefreshRotationResult.Invalid
|
||||
}
|
||||
val replacement = StoredOobeRefresh(
|
||||
grant = current.grant,
|
||||
tokenId = newTokenId,
|
||||
familyId = current.familyId,
|
||||
expiresAt = minOf(newExpiresAt, current.grant.expiresAt),
|
||||
)
|
||||
refreshes[newTokenHash] = replacement
|
||||
return OobeRefreshRotationResult.Rotated(replacement)
|
||||
}
|
||||
|
||||
override suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant? =
|
||||
grants[grantId]?.takeIf { it.subjectId == subjectId && it.expiresAt.isAfter(now) }
|
||||
|
||||
override suspend fun claim(
|
||||
request: OobeProviderRequest,
|
||||
expiresAt: Instant,
|
||||
now: Instant,
|
||||
): OobeRequestClaim? = error("not used")
|
||||
|
||||
override suspend fun markStarted(claim: OobeRequestClaim) = error("not used")
|
||||
override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) = error("not used")
|
||||
override suspend fun release(claim: OobeRequestClaim, errorCode: String) = error("not used")
|
||||
override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) = error("not used")
|
||||
}
|
||||
|
||||
private class MutableClock(var now: Instant) : Clock() {
|
||||
override fun getZone(): ZoneId = ZoneId.of("UTC")
|
||||
override fun withZone(zone: ZoneId): Clock = this
|
||||
override fun instant(): Instant = now
|
||||
}
|
||||
|
||||
private fun request(challenge: ByteArray) = CreateOobeGrantRequest(
|
||||
challengeId = UUID.randomUUID().toString(),
|
||||
challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challenge),
|
||||
keyId = KEY_ID,
|
||||
installationId = INSTALLATION_ID,
|
||||
assertion = Base64.getEncoder().encodeToString(byteArrayOf(1)),
|
||||
)
|
||||
|
||||
private fun sha256(value: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(value)
|
||||
|
||||
private val NOW = Instant.parse("2026-08-21T00:00:00Z")
|
||||
private val INSTALLATION_ID = UUID.fromString("10000000-0000-0000-0000-000000000001").toString()
|
||||
private val KEY_ID = Base64.getEncoder().encodeToString(ByteArray(32) { 3 })
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.osglab.account.features.oobe
|
||||
|
||||
import com.osglab.account.config.DatabaseConfig
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.gateway.models.GatewayCapability
|
||||
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
|
||||
import com.osglab.account.features.gateway.models.OobeFeature
|
||||
import com.osglab.account.features.gateway.models.ProviderUsage
|
||||
import com.osglab.account.features.gateway.models.UsageMeter
|
||||
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.credits.services.signupTrialIdempotencyKey
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.shouldNotBe
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.opentest4j.TestAbortedException
|
||||
import org.testcontainers.DockerClientFactory
|
||||
import org.testcontainers.containers.MySQLContainer
|
||||
import java.sql.DriverManager
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class OobeRepositoryIntegrationTest : FunSpec({
|
||||
test("anonymous feature claim is atomic, consumed once, and independent from accounts") {
|
||||
withOobeDatabase { config, databaseFactory ->
|
||||
val repository = ExposedOobeRepository(databaseFactory)
|
||||
val now = Instant.parse("2026-08-21T01:00:00Z")
|
||||
val subject = repository.findOrCreateSubject(
|
||||
keyId = "integration-key",
|
||||
installationHash = "a".repeat(64),
|
||||
subjectId = UUID.randomUUID().toString(),
|
||||
now = now,
|
||||
)
|
||||
val grant = OobeGrant(UUID.randomUUID().toString(), subject.id, now.plus(Duration.ofMinutes(30)))
|
||||
repository.createGrant(
|
||||
NewOobeGrant(
|
||||
grant = grant,
|
||||
refreshTokenId = UUID.randomUUID().toString(),
|
||||
refreshFamilyId = UUID.randomUUID().toString(),
|
||||
refreshTokenHash = "b".repeat(64),
|
||||
refreshExpiresAt = grant.expiresAt,
|
||||
),
|
||||
now,
|
||||
)
|
||||
|
||||
val claims = coroutineScope {
|
||||
(1..12).map { index ->
|
||||
async(Dispatchers.Default) {
|
||||
repository.claim(
|
||||
providerRequest(subject.id, grant.id, "concurrent-oobe-$index"),
|
||||
now.plus(Duration.ofMinutes(15)),
|
||||
now,
|
||||
)
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
val winningClaim = claims.filterNotNull().single()
|
||||
repository.markStarted(winningClaim)
|
||||
repository.consume(
|
||||
winningClaim,
|
||||
ProviderUsage(UsageMeter.LLM_TOKEN, 2, inputUnits = 1, outputUnits = 1),
|
||||
)
|
||||
repository.claim(
|
||||
providerRequest(subject.id, grant.id, "repeat-after-success"),
|
||||
now.plus(Duration.ofMinutes(15)),
|
||||
now,
|
||||
) shouldBe null
|
||||
|
||||
databaseCount(config, "accounts") shouldBe 0
|
||||
databaseCount(config, "credit_ledger") shouldBe 0
|
||||
databaseCount(config, "devicecheck_trial_claims") shouldBe 0
|
||||
|
||||
val accountId = UUID.randomUUID()
|
||||
insertAccount(config, accountId, now)
|
||||
val trial = CreditService(
|
||||
transactions = ExposedBillingTransactionRunner(databaseFactory.database),
|
||||
referralRewards = ReferralRewardConfig(
|
||||
inviterCredits = 1_000,
|
||||
inviteeCredits = 1_000,
|
||||
),
|
||||
).grantSignupTrial(
|
||||
userId = accountId,
|
||||
credits = 1_000,
|
||||
idempotencyKey = signupTrialIdempotencyKey(accountId),
|
||||
)
|
||||
trial.balance shouldBe 1_000
|
||||
}
|
||||
}
|
||||
|
||||
test("provider failure releases the feature for a retry") {
|
||||
withOobeDatabase { _, databaseFactory ->
|
||||
val repository = ExposedOobeRepository(databaseFactory)
|
||||
val now = Instant.parse("2026-08-21T02:00:00Z")
|
||||
val subject = repository.findOrCreateSubject(
|
||||
"release-key",
|
||||
"c".repeat(64),
|
||||
UUID.randomUUID().toString(),
|
||||
now,
|
||||
)
|
||||
val grant = OobeGrant(UUID.randomUUID().toString(), subject.id, now.plusSeconds(1_800))
|
||||
repository.createGrant(
|
||||
NewOobeGrant(
|
||||
grant,
|
||||
UUID.randomUUID().toString(),
|
||||
UUID.randomUUID().toString(),
|
||||
"d".repeat(64),
|
||||
grant.expiresAt,
|
||||
),
|
||||
now,
|
||||
)
|
||||
val first = repository.claim(
|
||||
providerRequest(subject.id, grant.id, "failure-first"),
|
||||
now.plusSeconds(900),
|
||||
now,
|
||||
)
|
||||
first shouldNotBe null
|
||||
repository.markStarted(requireNotNull(first))
|
||||
repository.release(first, "provider_failure")
|
||||
|
||||
repository.claim(
|
||||
providerRequest(subject.id, grant.id, "failure-retry"),
|
||||
now.plusSeconds(900),
|
||||
now,
|
||||
) shouldNotBe null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private fun providerRequest(subjectId: String, grantId: String, requestId: String) =
|
||||
OobeProviderRequest(
|
||||
subjectId = subjectId,
|
||||
grantId = grantId,
|
||||
feature = OobeFeature.ASK_AI,
|
||||
requestId = requestId,
|
||||
providerId = "integration-provider",
|
||||
capability = GatewayCapability.AI,
|
||||
purpose = GatewayRequestPurpose.OOBE,
|
||||
)
|
||||
|
||||
private suspend fun withOobeDatabase(
|
||||
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
|
||||
) {
|
||||
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) {
|
||||
OobeMySqlContainer("mysql:8.4")
|
||||
.withDatabaseName("osg_oobe_test")
|
||||
.withUsername("test")
|
||||
.withPassword("test")
|
||||
.also(OobeMySqlContainer::start)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val config = DatabaseConfig(
|
||||
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
|
||||
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
|
||||
?: mysql?.username
|
||||
?: "root",
|
||||
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
|
||||
maximumPoolSize = 12,
|
||||
)
|
||||
val databaseFactory = DatabaseFactory(config)
|
||||
try {
|
||||
databaseFactory.database
|
||||
block(config, databaseFactory)
|
||||
} finally {
|
||||
databaseFactory.close()
|
||||
mysql?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun databaseCount(config: DatabaseConfig, table: String): Long =
|
||||
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||
connection.createStatement().use { statement ->
|
||||
statement.executeQuery("SELECT COUNT(*) FROM $table").use { rows ->
|
||||
rows.next()
|
||||
rows.getLong(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertAccount(config: DatabaseConfig, accountId: UUID, now: Instant) {
|
||||
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
).use { statement ->
|
||||
statement.setString(1, accountId.toString())
|
||||
statement.setString(2, "oobe-signup-$accountId")
|
||||
statement.setTimestamp(3, java.sql.Timestamp.from(now))
|
||||
statement.setTimestamp(4, java.sql.Timestamp.from(now))
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class OobeMySqlContainer(image: String) : MySQLContainer<OobeMySqlContainer>(image)
|
||||
Reference in New Issue
Block a user